Unityで忍者アプリを作ってみた
さて、Unityで忍者アプリを作ってみました。 短期開発ですが、今回は欧米狙いの色が強いアプリに仕上げています。
https://itunes.apple.com/app/id891186517?at=10l8JW&ct=hatenablog
Android版はこちら↓
https://play.google.com/store/apps/details?id=com.cflat.ninja
フィールドはアセットを購入して、組み立てました。 同様に忍者も購入。
今回はNGUIも購入しました。 今回NGUIで使ったのはボタン程度ですが、基本的に使いやすいですね。 一つ困ったのは、画面サイズに合わせたボタンレイアウトの調整ですが、 [Unity]NGUIで画面サイズに合わせる(NGUI2.3.0対応版) の神記事に助けてもらいました。 よりデザインにこだわろうとすると、もちろんこれだけでは厳しいですが。
今回何より困ったのは、高速化。 iPhoneだと動作が猛烈に重く、ここに工数がかかりました。 原因はフィールドのアセットにあったのですが、そのせいでDrawcall数がかなり多くなっていた。(150くらいだったか) 「UnityでiPhone向け3Dゲームを作る」レポート - ゲームの花園
最初はアセットのpsdがものすごく重いことが原因だとかいろいろ当たりつけてやったんですが、やっぱりDrawcall数が最も重要。 畳なんかももっとカッチョ良かったのですが、削りまくって削りまくって何とかDrawcall数40くらいまで減らしました。 今回は使えなかったけど、GameObjectをStaticにしてしまうというのは、相当効果が大きい。忍者の動きに合わせてフィールドをどんどん移動させなくてはならんので、Staticにはできないのですが、一時はフィールドをスタティックにして忍者をワープさせようかとも考えたほどです。
あとはスポットライトもなかなかの負荷でした。 パーティクルはほどほどにしないとこれも重さの原因になってしまう。 アセットストアで購入するのも、いろいろ労が伴います。 何とか4Sでもある程度動くようになった。ふうー。 それでもまだ多少重いので、Androidだと機種によってはかなり重いので、人気出そうな感じなら改良かな。
そんなこんなでとにかくリリースしました。 良かったら触ってみてください。
C++からC++らしくJNIを呼んでみる
JNI(Java Native Interface)はJavaとCの連携技術です。Javaアプリケーションの一部ロジックをCで書いたり、さらにその中からJavaのメソッドを呼び出したりする事で、アプリケーションの実行環境が制限される代わりに高速化、あるいは非Javaプラットフォームのコードとの共通化が行なえる、という寸法です。
JavaからC++の関数を呼び出す場合
JNIを用いてJavaからC++の関数を呼ぶ場合、こんな感じになります:
// Java package com.cflat; public class Hoge { static { System.loadLibrary("hoge"); } public static native boolean printNative(String s, int x); public static boolean printJava(String s, int x) { System.out.println(s + " " + x); return true; } }
// C++ extern "C" { JNIEXPORT jboolean JNICALL Java_com_cflat_Hoge_print(JNIEnv *env, jstring s, jint x); }
C命名規則で、Java\_<i>packagename</i>\_<i>classname</i>\_<i>methodname</i>()
なる関数を作ってやればOK……C++的には嫌な感じの名前ですが、名前マングリングが実装依存になっているC++では、他の言語から関数を呼んで貰おうと思うとextern "C"
が必須ですので、まあ仕方ありません。せめてもう少し短い名前をだと言うのであればJNIではなくJNA(Java Native Access)を使うという方法が残されています。
C++からJavaのメソッドを呼び出す場合。
次に、今度はC++からJavaを呼び出してみます。
まず、C++側ではJavaVM
のインスタンスに対してGetEnv()
を呼び出す事でJNIEnv*
を取得しなければならないのですが、このJNIEnv*
の値はスレッドごとに変わってしまいます。
しかも、C++側で作られたスレッドには(当然ながら)最初はJNIEnv*
が作られていませんので、まずはAttachCurrentThread()
しなければなりません。もちろん、スレッド破棄時にはDetachCurrentThread()
が必要です。
ただ、その辺は今回したい話とは少しずれて来ますので省略して、既にJNIEnv*
を得たところから始めたいと思います。
そして……実際にやってみると、次のようにイケてない部分ばかりの代物になってしまいます:
// C++ bool printJava(JNIEnv *env, const std::string &s, int32_t x) { jclass klass = env->FindClass("com/cflat/Hoge"); jmethodID method = env->GetMethodID(klass, "printJava", "(Ljava/jang/String;I)Z"); // ←イケてないその1 jstring jstr = env->NewStringUTF(s.c_str()); jboolean result = env->CallStaticBooleanMethod(klass, method, jstr, x); // ←イケてないその2 env->DeleteLocalRef(jstr); // ←イケてないその3 env->DeleteLocalRef(klass); // ←┘ return (result != JNI_FALSE); }
JNIはC++でも使えるとは言っても、C用のインターフェースの最低限の部分を#if defined(__cplusplus)
〜#endif
で囲っただけのライブラリですから、仕方ない事ではあります。
が、もっとC++らしくするのであれば、せめてこんな形にしたいところでしょう:
// C++ bool printJava(jni::vm &vm, const std::string &s, int32_t x) // jni::vmはJavaVMのラッパー(自動的に適切なJNIEnv*を取得する) { jni::class_info klass = vm.get_class("com/cflat/Hoge"); // jni::class_infoはjclassのラッパー auto method = klass.get_static_method<bool(std::string, int32_t)>("printJava"); // シグネチャーは型指定で! return method(s, x); // 呼び出しも、get_static_method()に渡した型の通りにoperator()で! // jclassやらjstringやらはRAIIで勝手に破棄されるので、わざわざDeleteLocalRef()しなくてOK! }
JNIの型のC++らしいラッパーであるjni::vm
やjni::class_info
は、ある程度C++に慣れれば誰でも作れるはずですが(コピーコンストラクタと代入演算子でNewLocalRef()
、デストラクタと代入演算子でDeleteLocalRef()
を忘れないように!)、シグネチャーを型指定で済ますように作るには、かなりC++のテンプレートに慣れていないと難しいのではないでしょうか?
ものは試し、ちょっと作ってみましょう。
JNIラッパーの構成
上記のコードを見て気付いた方もいると思いますが、シグネチャーの型指定はJNIの型であるjstring
やjint
ではなく、C++の型であるstd::string
、int32_t
を使っています。何故なら、メソッド呼び出し処理の内部で自動変換してくれる方が扱いやすいので。
場合によっては、同じjstring
のインスタンスを複数のメソッド呼び出しで共有したい、という場合もあるかもしれませんが(当然、その方が余計なオーバーヘッドが不要になります)、今回は気にしないものとします。必要であればjstring
のラッパー←→jstring
の変換クラスを追加してやればよいだけでしょう。
これらを鑑みると、JNIのC++らしいラッパーの実装は、次のような部分から成るようにできるでしょう:
なお、jstring
(あるいは他のリファレンス型)をRAIIでいい感じに構築/破棄してくれる部分も1.の中に含まれるものとします。
C++←→JNI型
まずは、C++型を指定した時に対応するJNI型を求めるためのメタ関数が必要になります。 この部分をどう設計するかは悩ましいのですが、試しに、次のようにしてみましょう:
- 1/2/4/8バイトの符号付き整数型は、それぞれ
jbyte
/jshort
/jint
/jlong
に変換する((本当は「int8/16/32/64_t
のみ」としたかったところですが、int
で指定できないのもなんか嫌なので……)) - 4/8バイトの浮動小数型は、それぞれ
jfloat
/jdouble
に変換する char16_t
とjchar
はjchar
に変換する((sizeof(wchar_t) == 2
の場合、wchar_t
もjchar
にしていいかも))bool
とjboolean
はjboolean
に変換するtemplate <typename TNative> struct object_converter
をユーザーが明示的に特殊化したクラスはjobject
または互換性を持つ型に変換する
4.まではプリミティブ、5.はクラス型ですね。
……で、この部分を解説しようと思ったのですが、詳細に解説すると大変な事になるので、いろいろと上手く特殊化済みの次のクラスがあるものとします(コピー、ムーブ、デストラクタは省略してます)。
template <typename TNative> struct var { public: typedef TNative native_type; typedef 対応するJNI型 jni_type; var(JNIEnv *, native_type); var(JNIEnv *, jni_type); operator native_type() const; operator jni_type() const; private: jni_type m_type; };
このクラスの特殊ケースとして、インスタンス化のできない次のものを用意します:
template <> struct var<void> { typedef void native_type; typedef void jni_type; var() = delete; }; template <class TNativeRet, class ... TNativeParams> struct var<TNativeRet(TNativeParams...)> { typedef TNativeRet native_type(TNativeParams...); typedef typename var<TNativeRet>::jni_type jni_type(typename var<TNativeParams>::jni_type...); var() = delete; };
……これで、関数タイプのシグネチャーに対しても対応するJNIの関数タイプを求められるようになりました。
JNI型←→シグネチャー文字列
JNIのシグネチャー文字列には、大きく分けて4つのパターンがあります。
これを、できればコンパイル時にjint
と渡せば"I"、jstring
と渡せば"Ljava/lang/String;"、void(jstring, jint)
と渡せば"(Ljava/lang/String;I)V"のように文字列を構築できれば良いのですが、問題は2.のクラス名パターン。
というのも、std::string
は基本的にconstexpr
に対応していませんし、const char*
では文字列結合が困難だからです。テンプレートを使うとしても、テンプレートで文字列リテラルを与えられないので、コンパイル時に文字列構築をする事ができません(「文字列変数のアドレス」を指定する事だけは可能ですが、指示先をコンパイル時に参照できないので意味なし)。
こんな時、☆C++11テクニック☆ 配列を配列で初期化する方法+α - 株式会社CFlatの明後日スタイルのブログで紹介した方法を使って、constexpr
な文字列結合を行なってやればよいでしょう。
これだけわかっていれば、後は面倒なだけで大した事ないので割愛します。
とりあえずsignature<T>::get()
あたりで取れてくるようにすればいいでしょう。
JNI型←→CallXXXMethod
ここまで来れば、後は完全に作業ですね。まず、次のようなクラスを作り……
template <typename TJniRet> struct method_caller_info { static_assert(std::is_convertible<TJniRet, jobject>::value, "TJniRet is not a primitive type, void, nor jobject"); static auto static_method_caller() -> decltype(&JNIEnv::CallStaticObjectMethod) { return &JNIEnv::CallStaticObjectMethod; } static auto instance_method_caller() -> decltype(&JNIEnv::CallObjectMethod) { return &JNIEnv::CallObjectMethod; } static auto nonvirtual_method_caller() -> decltype(&JNIEnv::CallNonvirtualObjectMethod) { return &JNIEnv::CallNonvirtualObjectMethod; } }; template <typename TJniRet> struct method_caller : method_caller_info<TJniRet> { typedef TJniRet result_type; template <typename ... TJniParams> static result_type call_static(const class_info &klass, jmethodID method_id, TJniParams ... params) { return (result_type)(klass.jni_env()->*method_caller_info<TJniRet>::static_method_caller())(klass.jni_value(), method_id, params ...); } template <typename TJObject, typename ... TJniParams> static result_type call_instance(const object<TJObject> &obj, jmethodID method_id, TJniParams ... params) { return (result_type)(obj.jni_env()->*method_caller_info<TJniRet>::instance_method_caller())(obj.jni_value(), method_id, params ...); } template <typename TJObject, typename ... TJniParams> static result_type call_nonvirtual(const object<TJObject> &obj, const class_info &klass, jmethodID method_id, TJniParams ... params) { if (obj.jni_env() != klass.jni_env()) throw env_mismatch(); return (result_type)(obj.jni_env()->*method_caller_info<TJniRet>::nonvirtual_method_caller_type())(obj.jni_value(), klass.jni_value(), method_id, params ...); } };
プリミティブ型用に、C++らしくなくて嫌ですが、こういう時はCプリプロセッサマクロにお任せ。
#define DEFINE_PRIMITIVE_METHOD_CALLER(jni_primitive_type, methodName) \ template <> \ struct method_caller_info<jni_primitive_type> \ { \ static auto static_method_caller() -> decltype(&JNIEnv::CallStatic##methodName##Method) { return &JNIEnv::CallStatic##methodName##Method; } \ static auto instance_method_caller() -> decltype(&JNIEnv::Call##methodName##Method) { return &JNIEnv::Call##methodName##Method; } \ static auto nonvirtual_method_caller() -> decltype(&JNIEnv::CallNonvirtual##methodName##Method) { return &JNIEnv::CallNonvirtual##methodName##Method; } \ }; DEFINE_PRIMITIVE_METHOD_CALLER(void, Void); DEFINE_PRIMITIVE_METHOD_CALLER(jboolean, Boolean); DEFINE_PRIMITIVE_METHOD_CALLER(jbyte, Byte); DEFINE_PRIMITIVE_METHOD_CALLER(jchar, Char); DEFINE_PRIMITIVE_METHOD_CALLER(jshort, Short); DEFINE_PRIMITIVE_METHOD_CALLER(jint, Int); DEFINE_PRIMITIVE_METHOD_CALLER(jlong, Long); DEFINE_PRIMITIVE_METHOD_CALLER(jfloat, Float); DEFINE_PRIMITIVE_METHOD_CALLER(jdouble, Double);
これでまず、戻り値の型を指定して適切なCallXXXMethod()
を取得してくれるクラスmethod_caller
が作れました。後は、実際に呼び出す処理を作ればよいでしょう。
例によっていろいろと省略して、static
メソッドクラスだけ実装してみます:
class method_info { protected: typedef jmethodID (JNIEnv::*get_method_func_type)(jclass klass, const char* name, const char* signature); method_info(const class_info &klass, get_method_func_type func, const char *name, const char *signature); class_info m_class; jmethodID m_method; }; template <class TRet> struct static_caller { template <class ... TMethodParams> static typename marshal::var<TRet>::native_type call(const class_info &klass, jmethodID method, TMethodParams ... params) { typedef typename marshal::method_caller<typename marshal::var<TRet>::jni_type> method_caller; auto retval = method_caller::call_static(klass, method, params ...); return (typename marshal::var<TRet>::native_type)marshal::var<TRet>(klass.jni_env(), retval); } }; template <> struct static_caller<void> { template <class ... TMethodParams> static void call(const class_info &klass, jmethodID method, TMethodParams ... params) { typedef typename marshal::method_caller<void> method_caller; method_caller::call_static(klass, method, params ...); } }; template <class TRet, class ... TParams> class static_method_info<TRet(TParams...)> : private method_info { public : static_method_info(const class_info &klass, const char *name) : method_info(klass, &JNIEnv::GetStaticMethodID, name, marshal::signature<TRet(TParams...)>()) {} typename marshal::var<TRet>::native_type operator()(const TParams & ... params) { if (!m_method) throw std::runtime_error("jni: Invalid method is called."); return static_caller<TRet>::call(m_class, m_method, (typename marshal::var<TParams>::jni_type)marshal::var<TParams>(m_class.jni_env(), params) ...); } };
途中のstatic_caller
クラスは、戻り値がvoid
とそれ以外で、戻り値の型変換をする必要があるかどうかが変わるため。関数テンプレートは部分特殊化ができないため、クラスを作っています。
実際のコード
実際に、弊社アプリ『Fuse』のAndroid版で、上記の仕組みを使ってみました。
Fuseはなるべく共通のソースコードを用いてiPhone/Android両対応できるよう、Cocos2d-xにて開発されていますので、基本的な部分はC++で開発し、プラットフォーム依存な部分のみをObjective-C++、あるいはJava+JNIで開発しています((一応、Cocos2d-xのAndroid用プロジェクトには、JNIEnv*
の取得部分だけを微妙に使い易くしたようなヘルパークラスがあるのですが))。
Fuseでは、例えばGoogle Analyticsのイベント送信等をC++コードから行なうために、Javaで次のようなクラスを作っています:
package com.cflat; import com.google.analytics.tracking.android.*; import android.app.Activity; public class GoogleAnalyticsTracker { static Activity m_activity = null ; public static void sendAppView(String screenName) { if (m_activity == null) return ; EasyTracker tracker = EasyTracker.getInstance(m_activity) ; tracker.set(Fields.SCREEN_NAME, screenName); tracker.send(MapBuilder.createAppView().build()); } public static void sendEvent(String category, String action, String label, int value) { if (m_activity == null) return ; EasyTracker tracker = EasyTracker.getInstance(m_activity) ; tracker.send(MapBuilder.createEvent(category, action, label, Long.valueOf(value)).build()) ; } public static void initialize(Activity activity) { m_activity = activity ; EasyTracker.getInstance(m_activity).activityStart(m_activity); } public static void terminate() { EasyTracker.getInstance(m_activity).activityStop(m_activity); } }
これを、C++側からは次のような形で呼び出しています:
using namespace cflat::jni; bool Tracker::sendAppViewImpl(const char *screenName) { auto method = vm::instance().get_class(CLASS_NAME).get_static_method<void(std::string)>("sendAppView") ; if (!method) return false ; method(screenName) ; return true ; } bool Tracker::sendEventImpl(const char *category, const char *action, const char *label, int value) { auto method = vm::instance().get_class(CLASS_NAME).get_static_method<void(std::string, std::string, std::string, int)>("sendEvent") ; if (!method) return false ; method(category, action, label, value) ; return true ; }
JNIをそのまま使うのと比べ、かなりわかりやすくなっている事がおわかりいただけるかと思います。
cocos2d::CCGLProgramがアレなワケ
前回のオマケ的な何か。
前回記事の最後で、私は次のように書きました:
ともあれ、上記の結果から、原則として
for
版を使いつつ、for
版が使えない時にだけ非for
版を使ってやればよさそうだとわかります。
実はこの部分にもこっそりと妙な罠が潜んでいるのですが、これについてはまた別の記事にて。
というわけでその「別の記事」が今回です。
cocos2d::CCGLProgram
のおかしな仕様
まず、次のコードを、適当なところに書いてみます。さて、どのようになるでしょうか?
コードは Cocos2d-x 2系ですが、3系でも同様の事が起こるので試してみて下さい。
CCGLProgram *prog = new CCGLProgram(); bool result = prog->initWithVertexShaderByteArray("a invalid vertex shader.", "a invalid fragment shader."); CCLog(result ? "success" : "failure"); CC_SAFE_DELETE(prog);
この時、"a invalid vertex(fragment) shader."
というのは明らかに GLSL としては不正な文字列ですから、initWithVertexShaderByteArray()
は失敗するでしょう。常人であれば、CCGLProgram
自身は未使用のまま削除されるので描画には全く影響を及ぼさず、幾つかのコンパイルエラーメッセージと "failure" が出力されるだけ、と考えるのが普通だろうと思います。
が……何と、実際は 違います 。
かといってもちろん "success" が出力されるわけでもなく、あろう事かプログラムが 強制終了 という憂き目に遭うでしょう。
一体どういうこと?
この、にわかには信じ難い仕様の原因を、試しに Cocos2d-x のソースコードを読んで探ってみます。
bool CCGLProgram::compileShader(GLuint * shader, GLenum type, const GLchar* source) { GLint status; //// 中略 //// glGetShaderiv(*shader, GL_COMPILE_STATUS, &status); if (! status) { //// 中略 //// #if (CC_TARGET_PLATFORM == CC_PLATFORM_WINRT) return false; #else abort(); #endif } return (status == GL_TRUE); }
……おわかりでしょうか? この燦然と輝くabort();
の文字。
! status
という条件は GLSL のコンパイルに失敗した時のものですから、どうやら Cocos2d-x は GLSL のコンパイルに失敗すると問答無用でプログラムを異常終了させてくれるようです。
普通に考えてありえない。一体、何のために引数がbool
型になっているのやら(どうやらsource == nullptr
の時にだけfalse
みたいですよ)……というわけで皆さんは、Cocos2d-x を導入したら必ず、このabort()
は削りましょう。
ライブラリ側を弄りたくないよって人は
自分だけで開発しているならともかく、複数人で開発している場合、Cocos2d-x のライブラリ側はなるべく弄りたくないでしょう。
そんな時は、CCGLProgram::compileShader()
を参考に、コンパイル可能かどうか調べる関数を作ってやりましょう。
やる事は簡単です。ほとんどCCGLProgram::compileShader()
をコピってくるだけ。
/** * GLSLソースコードがコンパイル可能かどうか確認する * * @param GLenum type シェーダのタイプ(GL_VERTEX_SHADER, GL_FRAGMENT_SHADER, ...) * @param GLchar* source GLSLソースコード */ bool isCompilable(GLenum type, const GLchar *source) { const GLchar *sources[] = { #if (CC_TARGET_PLATFORM != CC_PLATFORM_WIN32 && CC_TARGET_PLATFORM != CC_PLATFORM_LINUX && CC_TARGET_PLATFORM != CC_PLATFORM_MAC) (type == GL_VERTEX_SHADER ? "precision highp float;\n" : "precision mediump float;\n"), #endif "uniform mat4 CC_PMatrix;\n" "uniform mat4 CC_MVMatrix;\n" "uniform mat4 CC_MVPMatrix;\n" "uniform vec4 CC_Time;\n" "uniform vec4 CC_SinTime;\n" "uniform vec4 CC_CosTime;\n" "uniform vec4 CC_Random01;\n" "//CC INCLUDES END\n\n", source, } ; GLuint shader = glCreateShader(type); glShaderSource(shader, sizeof(sources)/sizeof(*sources), sources, nullptr); glCompileShader(shader); GLint status ; glGetShaderiv(shader, GL_COMPILE_STATUS, &status); bool result = (status != GL_FALSE) ; glDeleteShader(shader) ; return result ; }
CCGLProgram::initWithVertexShaderByteArray()
にシェーダのソースを渡す前に、一旦この関数で有効性を確認する事にします。もしくは、CCGLProgram
を継承したクラスを作って、initWithVertexShaderByteArray()
の前に必ず有効性チェックをかける事にしても構いません(ただし、CCGLProgram::initWithVertexShaderByteArray()
は non-virtual なので、そこだけ注意)。
こうしておけば、デバイスごとに最適なシェーダプログラムを走らせる事が容易になるでしょう。
ワールドカップに合わせてUnityでサッカーゲームを作ってみた
AppStoreの審査が開幕のブラジル戦に間に合うのかどうか、これが一番の懸案でしたが、1回リジェクトくらったため間に合いませんでした。コートジボワール戦にも間に合わず、何とかギリシャ戦には間に合ったという惨状です。
さて、会社でUnity Proも購入したし、まずは一つリリースしてみようということで、サッカーゲームを作ってみました。 開発期間はかなり短期間です。
https://itunes.apple.com/app/id885300966?at=10l8JW&ct=hatenablog
Android版はこちら↓
https://play.google.com/store/apps/details?id=com.cflat.ClassicDrivingShot
スタジアムとキーパーはアセットを購入しました。 購入するまで、アニメーションがわからないアセットがあるので、この点ストアを改善してほしいですな。 結局一人目のキーパーは使えないキーパーだったので、二人目を購入しました。 世界中探しても、なかなか理想的なキーパーは見当たらないものです。 あと、サウンドも購入するまで音がわからないというのは、買う勇気が湧かない。。。 時間もないし、サウンドはフリーのものを使いました。
あとは、フリックでドライブシュートを打てるようにして、キーパーのColliderをちまちま作ってやって、ドライブシュートの軌道からキーパーの飛ぶ方向を計算して、飛ばしてやる。 ドライブは、フリックダウンの時間に比例して力を加えています。 距離を使うと、パラメータ調整がかなり難しくなりそうだったので。 ゴールが決まった場所に応じて、1ゴール、2ゴール、4ゴール、8ゴール、16ゴールまで獲得でき、これが一つのインセンティブです。 16ゴールは基本的に幻のゴールで、上隅の限られた領域に限定されます。開発者も3回くらいしか出したことない。
GUIは評判通り、開発しにくい。 NGUI買うべきなんでしょうが、今回はひとまずフリーでやりました。 次は購入かなあ。
あとは実機デバッグがやっぱり問題ですな。 Unity Remoteは素晴らしく、ドライブシュートのフリック部分の開発はこれでかなり助けられました。 が、画質も悪く、GUI周りの確認とか、結局実機出力して確認という作業を何回もやりました。 そんでもってネイティブ出力がとても遅い。
タイトル画像がダサいけど、ダサいからアンインストールするって人はほとんどいないだろうし、他にもいろいろ導入したいものもあったけど、タイムリミットが来たので、ひとまずリリースして、その辺はおいおい。。。 マルチプラットフォームの素晴らしさも享受できたけど、元が取れるかな。
空前のパズルブームを予想してCocos2dでパズルゲームを作ってみた
この度、弊社よりパズルゲーム『Fuse 〜 導火線パズルドカーン! 〜』(以下『Fuse』)がリリースされました。
開発はCocos2d-x 2.2.2で行ないました(今までちょこちょことCocos2d-x関連の記事があったのは、これのための研究のせい*1)。 本当は3.0系を使おうと思ったのですが、その時はまだalpha版だったのでやめておきました……C++11で多少使い易くなっただけで、本質的な機能改善はあんまりなさそうでしたし。
さて、Fuseの話に戻りましょう。 ルールは簡単、画面の上の方に並んでいる導火線パネルを下に置いて、爆弾と爆弾の間を導火線パネルで繋いで爆破するだけ!
ステージ数は全60+隠し1。クリア時にTwitter・Facebook・LINE等に投稿できる仕組みを備えています。 この辺りの仕組みを、iOSでもAndroidでもなるべくCocos2d-xから共通で呼び出せるよう、一部、Objective-C++やJava+JNIにて開発。JNI部分については後日、別途記事にしてみたいと思います。
*1:結局、Androidで例のGLSLを走らせるのは不安要素が大きすぎたので、結局断念したのですが……。
続・Cocos2d-xの輪郭線とか影回りを実用的に魔改造してみた
以前の記事に引き続き。
以前、「輪郭線が iOS と Android にしか対応していなくて云々」というような理由で GLSL を用いた輪郭線表示を行なうようにしましたが、これを Android で実行してみると機種によってはこうなります。
error C5013: profile does not support "for" statements and "for" could not be unrolled.
つまり、GPU だかドライバだかが GLSL のfor
ループ(もちろんwhile
と、ついでにswitch
にも)には対応していない、というエラーです。はて困りました。
こんな時、どうすればいいかは簡単です。
for
ループが使えないなら、 ループを展開した GLSL を動的生成しちゃえばいいじゃない 。
というわけでループを展開してみた……ところ
以前の記事でループを使っていた部分は2ヵ所。影を複数つけられるようにしたので影ごとにループを回す部分と、それぞれのピクセルが影のぼかし部分や輪郭線の部分に入っていないか、周囲のテクスチャのピクセルを探す部分です。
前者は最大数がkCCDecorationLabelMaxShadowCount
で決められていたので簡単ですが、後者が困難。というのも、影の大きさ次第でループ範囲が変わるので、ループ範囲を決め打ちできないのです。
なら、描画する度に影のサイズをチェックして、影が前回よりも大きくなっていたらループ範囲を増やせばよいのですが、実際にやってみると極めて重大な問題が持ち上がりました。シェーダの実行が、恐ろしく重いのです……具体的にはテクスチャ1枚に分単位で時間がかかります。
理由を調べていったところ、どうやら GLSL のif
文は、条件を満たすかどうかにかかわらず、常に分岐後の式を評価して、後で結果を捨てる模様。そりゃあ不要な処理が大量に増えるわけで、遅くなるのも当たり前です。
なので、折角コードを動的生成するのだから、全ての影についてなるべく必要最低限のみを使うようにしてしまいましょう。少々面倒ですが、毎回影の数とサイズをチェックして、一度使った事のあるパターンであればキャッシュしたプログラムを使い、そうでなければ新たにプログラムを生成してキャッシュに突っ込む感じです。
例えばこんな感じに
以前と比べると、uniform
変数の種類や意味が変わっていますが、説明は割愛します。今回の記事とは無関係なところではありますが、除算よりも高速な乗算を使うため、texGeom.xy
を逆数で入れるようにしてあるところは注意です。
あと、なんかよくわからなかったαが2乗される問題がまだ解決できていないため、gl_FragColor = color;
ではなくgl_FragColor = vec4(color.rgb, sqrt(color.a));
になっています。
メインパーツ
まずは、メインとなるGLSLパーツ。
uniform sampler2D texture; /* ベース・テクスチャ */ uniform vec4 tintColor; /* 文字色 */ uniform vec4 texGeom; /* vec4(1/texture.width, 1/texture.height, texture.x, texture.y) */ uniform vec4 shadowOuterColors[$OUTER_SHADOW_COUNT$]; /* 外側の影の色 */ uniform vec4 shadowOuterShapes[$OUTER_SHADOW_COUNT$]; /* 外側の影の形状 vec4(dx, dy, extent, blur) */ uniform vec4 shadowInnerColors[$INNER_SHADOW_COUNT$]; /* 内側の影の色 */ uniform vec4 shadowInnerShapes[$INNER_SHADOW_COUNT$]; /* 内側の影の形状 vec4(dx, dy, extent, blur) */ /* ベース・テクスチャのアルファを求める。アンチエイリアスのかかっている場所では 0 と 1 の間を取る */ float baseAlpha(vec2 coord) { return texture2D(texture, coord * texGeom.xy).a; } /* (-∞, edge0] の区間で 0 、(edge0, edge1) の区間で 0〜1 、[edge1, +∞) の区間で 1 となるような関数 */ float linearstep(float edge0, float edge1, float x) { return clamp((x - edge0) / (edge1 - edge0), 0.0, 1.0); /* 使用法的に、常にedge0<edge1の保証有り */ } /* 下の色と上の色をアルファつきで混ぜる */ vec4 mergeOpaqueColors(vec4 under, vec4 over) { if (over.a == 0.0) return under; /* 上の色は透明 */ float under_a = under.a * (-over.a) + under.a; float a = over.a + under_a; return vec4((over.rgb * over.a + (under.rgb * under_a)) / a, a); } /* 文字の外側のピクセルについて、文字輪郭までの距離を求める */ float getOuterDist(vec2 pos, float rMin, float len) { float a = baseAlpha(pos); /* 以下、if (a != 0) return min(rMin, len - a); else return rMin; を高速化したもの */ return mix(rMin, min(rMin, len - a), step(0.0001, a)); /* 0 < 0.0001 < 1/255 */ } /* 文字の内側のピクセルについて、文字輪郭までの距離を求める */ float getInnerDist(vec2 pos, float rMin, float len) { float a = 1.0 - baseAlpha(pos); /* 内側ならば、アルファを反転 */ /* 以下、if (a != 0) return min(rMin, len - a); else return rMin; を高速化したもの */ return mix(rMin, min(rMin, len - a), step(0.0001, a)); /* 0 < 0.0001 < 1/255 */ } $OUTER_SHADOW_FUNCS$ $INNER_SHADOW_FUNCS$ void main(void) { /* 外側の影を描画 */ vec4 color = vec4(0.0, 0.0, 0.0, 0.0); $DRAW_OUTER$ /* フォント色で描画 */ float face_a = baseAlpha(gl_FragCoord.xy - texGeom.zw); if (0.0 != face_a) { vec4 innerColor = tintColor; /* 文字の内部なので、内側の影を描画 */ $DRAW_INNER$ /* アンチエイリアス分を考慮しつつ、外側の影とフォント色を合成 */ color = mergeOpaqueColors(color, vec4(innerColor.rgb, innerColor.a * face_a)); } gl_FragColor = color; }
ここで、$〜$
で囲まれた部分は、動的生成のためのプレースホルダです。$〜$
でなくとも、好きなようにプレースホルダを作ればよいのですが。
それぞれ、
$OUTER_SHADOW_COUNT$
: 文字の外側に発生させる影の数$INNER_SHADOW_COUNT$
: 文字の内側に発生させる影の数$OUTER_SHADOW_FUNCS$
: 動的生成される、外側の影の計算用の関数$INNER_SHADOW_FUNCS$
: 動的生成される、内側の影の計算用の関数$DRAW_OUTER$
: 動的生成される、外側の影色の合成処理$DRAW_INNER$
: 動的生成される、内側の影色の合成処理
を意味します。$OUTER_SHADOW_COUNT$
と$INNER_SHADOW_COUNT$
は自明なので、重要なのはその他4つです。
影の合成処理
次に、$DRAW_OUTER$
と$DRAW_INNER$
を見てゆきましょう。
と言っても、実のところ大したことはしていなかったり。
・$DRAW_OUTER$
color = mergeOuterShadow$i$(color); /* $i$番目の外側の影 */
・$DRAW_INNER$
innerColor = mergeInnerShadow$i$(innerColor); /* $i$番目の内側の影 */
……はい。本当に全く大したことありませんね。これを外側/内側の影の数だけ繰り返しながら、それぞれについて$i$
を影のインデックスに書き換えてやればいい話です。具体的には、例えばこんな形に埋め込まれるわけですね。
void main(void) { /* 外側の影を描画 */ vec4 color = vec4(0.0, 0.0, 0.0, 0.0); color = mergeOuterShadow0(color); /* 0番目の外側の影 */ color = mergeOuterShadow1(color); /* 1番目の外側の影 */ /* フォント色で描画 */ float face_a = baseAlpha(gl_FragCoord.xy - texGeom.zw); if (0.0 != face_a) { vec4 innerColor = tintColor; /* 文字の内部なので、内側の影を描画 */ innerColor = mergeInnerShadow0(innerColor); /* 0番目の内側の影 */ /* アンチエイリアス分を考慮しつつ、外側の影とフォント色を合成 */ color = mergeOpaqueColors(color, vec4(innerColor.rgb, innerColor.a * face_a)); } gl_FragColor = vec4(color.rgb, sqrt(color.a)); }
そして、このmergeOuterShadow$i$()
とmergeInnerShadow$i$()
をどう動的生成してやるか、というのが今回の話のキモになります。
影の計算関数の動的生成
関数としては Inner/Outer で別れていますが、やっている事はほぼ同じですので、テンプレートは共通の1種類で済みます。
プレースホルダ$INNER_OUTER$
に、"Inner" または "Outer" が入るイメージ。$CHECK_AROUND_i$
が2つめのループである、周囲の影を取得してくる部分です。$CHECK_AROUND_i$は関数ごとに別々の内容が入ります。
/* $i$番目の影の色を追加する */ vec4 merge$INNER_OUTER$Shadow$i$(vec4 lastColor) { vec4 shape = shadow$INNER_OUTER$Shapes[$i$]; /* 参照するベース・テクスチャ上の位置 */ vec2 center = gl_FragCoord.xy - texGeom.zw - shape.xy; float rMin = shape.z; /* rMin=文字との最短距離 */ /* 太くした部分の範囲内かどうかを確認するために、centerの周辺のテクスチャ上のピクセルを確認 */ $CHECK_AROUND_i$ if (shape.z <= rMin) return lastColor; /* 影の外側 */ /* ぼやけを考慮した影の濃さを求める */ vec4 col = vec4(shadow$INNER_OUTER$Colors[$i$].rgb, shadow$INNER_OUTER$Colors[$i$].a * linearstep(-shape.z, 1.0 - shape.w, -rMin)); /* linearstep(-a, -b, -x) == 1.0 - linearstep(b, a, x) */ /* 現在色と合成 */ return mergeOpaqueColors(lastColor, col); }
$CHECK_AROUND_i$
のテンプレートは、こんな感じです:
rMin = get$INNER_OUTER$Dist(center + vec2($x$.0, $y$.0), rMin, $length$);
ここで、$x$, $y$
は、半径=shadow$INNER_OUTER$Shapes[$i$].z
の円の内部に入る全てのピクセル、$length$
は中心からそのピクセルの間の距離となります(length(vec2($x$, $y$))
と一致)。
最終的に、merge$INNER_OUTER$Shadow$i$
は例えば次のような形に実体化される事になります。
/* 0番目の影の色を追加する */ vec4 mergeOuterShadow0(vec4 lastColor) { vec4 shape = shadowOuterShapes[0]; /* 参照するベース・テクスチャ上の位置 */ vec2 center = gl_FragCoord.xy - texGeom.zw - shape.xy; float rMin = shape.z; /* rMin=文字との最短距離 */ /* 太くした部分の範囲内かどうかを確認するために、centerの周辺のテクスチャ上のピクセルを確認 */ rMin = getOuterDist(center + vec2(0.0, -1.0), rMin, 1.000000); rMin = getOuterDist(center + vec2(-1.0, 0.0), rMin, 1.000000); rMin = getOuterDist(center + vec2(0.0, 0.0), rMin, 0.000000); rMin = getOuterDist(center + vec2(1.0, 0.0), rMin, 1.000000); rMin = getOuterDist(center + vec2(0.0, 1.0), rMin, 1.000000); if (shape.z <= rMin) return lastColor; /* 影の外側 */ /* ぼやけを考慮した影の濃さを求める */ vec4 col = vec4(shadowOuterColors[0].rgb, shadowOuterColors[0].a * linearstep(-shape.z, 1.0 - shape.w, -rMin)); /* linearstep(-a, -b, -x) == 1.0 - linearstep(b, a, x) */ /* 現在色と合成 */ return mergeOpaqueColors(lastColor, col); }
実行速度の比較
かなり大きめの影を取って以前のコード(をもう少し改良して速くしたもの)と比べると、次のような差がありました。
Device | 旧コード | 今回コード |
---|---|---|
iPod touch(第5世代) | 970ms | 14706ms |
Nexus 7 | GLSLコンパイルエラー | 1360ms |
Galaxy SII(SC-02C)※フォント小 | 0ms | 0ms |
Galaxy SII(SC-02C)※フォント大 | 0ms(表示崩れ) | アプリ強制終了 |
……Galaxy SII の当てにならなさが酷い。表示崩れの時はGL_OUT_OF_MEMORY
が返ってきてるので、どうやらテクスチャメモリ確保に失敗している模様です。フォントと影の大きさが大きすぎたんでしょうね。それにしても、確保失敗したのにそのままnullptr
も返さず処理を続行するcocos2d::CCRenderTexture::create()
さんが漢らしすぎる。他のcreate()
系関数のnullptr
返す仕様と合わさってればいいのに。
ともあれ、上記の結果から、原則としてfor
版を使いつつ、for
版が使えない時にだけ非for
版を使ってやればよさそうだとわかります。
実はこの部分にもこっそりと妙な罠が潜んでいるのですが、これについてはまた別の記事にて。
この本、おすすめです。
☆C++11テクニック☆ 配列を配列で初期化する方法+α
メンバー変数の初期化
例えば、こんなコードがあったとします。
template <class T> struct Hoge { Hoge(const T &v) : value(v) {} T value; }; template <class T> Hoge<T> make_hoge(const T &v) { return Hoge<T>(v); } #include <iostream> #include <cxxabi.h> #include <cstdlib> #include <memory> template <class T> std::ostream &print(std::ostream &os, const T &value) { int status; std::unique_ptr<char, decltype(&std::free)> demangled(abi::__cxa_demangle(typeid(value).name(), 0, 0, &status), &std::free); return os << demangled.get() << ": " << value << std::endl; } int main() { auto hoge = make_hoge(142857); print(std::cout, hoge.value); return 0; }
結果はもちろん、こうなります。
int: 142857
メンバー変数が配列になると……?
では次に、make_hoge()
に渡す実引数を次のように変えてみましょう。
constexpr auto hoge = make_hoge("142857");
コンパイルすると、次のようなエラーが発生します。
main.cpp: In instantiation of 'Hoge<T>::Hoge(const T&) [with T = char [7]]': main.cpp:12:19: required from 'Hoge<T> make_hoge(const T&) [with T = char [7]]' main.cpp:30:29: required from here main.cpp:4:29: error: array used as initializer Hoge(const T &v) : value(v) {}
どうやら、「配列をvalue(v)
の形式で初期化できないよ」と言われているようです。
仕方ないので、配列の時だけコンストラクタを特殊化して、次のようにしてみましょう:
template <class T, int N> struct Hoge<T[N]> { Hoge(const T (&v)[N]) { std::copy_n(&v[0], N, &value[0]); } T value[N]; };
出力は、こうです。
char [7]: 142857
おお、上手く行きました!
では次は、このクラスをコンパイル時に構築できるよう、Hoge
のコンストラクタやmake_hoge()
にconstexpr
をつけてみましょう。
コンパイル結果は、こうなります:
main.cpp: In constructor 'constexpr Hoge<T [N]>::Hoge(const T (&)[N])': main.cpp:14:71: error: constexpr constructor does not have empty body constexpr Hoge(const T (&v)[N]) { std::copy_n(&v[0], N, &value[0]); } ^ main.cpp: In function 'int main()': main.cpp:40:39: error: the type 'const Hoge<char [7]>' of constexpr variable 'hoge' is not literal constexpr auto hoge = make_hoge("142857"); ^ main.cpp:12:8: note: 'Hoge<char [7]>' is not literal because: struct Hoge<T[N]> ^ main.cpp:12:8: note: 'Hoge<char [7]>' is not an aggregate, does not have a trivial default constructor, and has no constexpr constructor that is not a copy or move constructor
どうやら、constexpr
なコンストラクタに中身がある事がお気に召さない様子。C++14では(std::copy_n()
がconstexpr
でさえあれば)大丈夫なのかもしれませんが、少なくともgcc4.9.0では-std=c++1y
をつけてやってもダメです。
では、どうすればよいのでしょうか?
さて、ここでC++の配列初期化方法を思い出してみます。
int hoge[10] = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9};
つまり、この方法を利用すれば、コンストラクタの中身を空にする事ができるのでは? ……はい、その通りです。
template <class T, int N> struct Hoge<T[N]> { constexpr Hoge(const T (&v)[N]) : value{/* 0, 1, 2, ... */} {} T value[N]; };
それではこのvalue{}
の中身は、どう書けばよいのでしょうか……というのが今回の本題になります。ああ長かった。
N要素の配列メンバー変数の初期化
配列の要素数ごとにvalue
の初期化子の要素数が変わるので、任意のN
に対して適切な初期化子を書くのは非常に困難なように見えます。
C++でメタプログラミングを始めたばかりの人であれば、最初に思いつくのはこんなコードでしょう:
template <class T> struct Hoge<T[1]> { constexpr Hoge(const T (&v)[1]) : value{v[0]} {} T value[1]; }; template <class T> struct Hoge<T[2]> { constexpr Hoge(const T (&v)[2]) : value{v[0], v[1]} {} T value[2]; }; template <class T> struct Hoge<T[3]> { constexpr Hoge(const T (&v)[3]) : value{v[0], v[1], v[2]} {} T value[3]; };
利用する全てのN
について、コンストラクタを特殊化してしまえばよろしい。解決ですな。
……もちろん、そんな頭の悪い事など推奨できるはずもありません。だってこれ、#define
すらできないじゃん。できてもやりませんが。
C++11erであれば、当然次のように書くでしょう:
template <class T, int N> struct Hoge<T[N]> { template <int ... i> constexpr Hoge(const T (&v)[N]) : value{v[i] ...} {} T value[N]; };
さて問題は、このint ... i
をどのように生成するか、になります。
もちろん、Hoge<T[N]><0, 1, 2>("10")
なんて事を手動で書きたくはありません。i
の数を間違えると、余裕で配列外にアクセスしてしまいますし。
Nから0...N-1を作る方法
こんな時、N
から0...N-1
のリストを作れるようにすれば、物事は万事解決です。
ちょっとだけクラス数は増えますが、こんな感じになればよいでしょうか?
template <int N> class make_indices { public: template <int ... i> struct indices {}; typedef indices<i ...> type; // ←ココ }; template <class T, int N> class Hoge_impl { friend class Hoge<T[N]>; // コンストラクタのみHoge<T[N]>に公開する private: template <int ... i> constexpr Hoge_impl(const T (&v)[N], indices<i...>) : value{v[i] ...} {} public: T value[N]; }; template <class T, int N> struct Hoge<T[N]> : public Hoge_impl<T, N> { constexpr Hoge(const T (&v)[N]) : Hoge_impl<T, N>(v, typename make_indices<N>::type()) {} };
この、make_indices<N>::type
を生成する方法さえ見つかれば、全ては解決です。
アイディアとしては、テンプレートの再帰を使います。例えばこんな感じ:
template <int N> class make_indices { private: // 最初はM==Nで、Mを1つ減らすごとにsizeof...(i)を1増やす template <int M, int ... i> struct make_indices_impl : public make_indices_impl<M - 1, i ... , sizeof...(i)> {} ; // M==0となったので、i...には「0〜N-1」のN連続の値が入っている // この時のindices<i...>が、求めるindices<i...> template <int ... i> struct make_indices_impl<0, i...> { typedef indices<i ...> type ; } ; public: typedef typename make_indices_impl<N>::type type; };
実行してみると、
char [7]: 142857
この通り、予期した通りに動作する事がわかります。
オマケ:今のの応用
これを応用すると、配列をコンパイル時に逆転させてみたり、
template <class T, int N> class Hoge_impl { template <int M, int ... i1, int ... i2> constexpr Hoge_impl(const T (&v1)[M], indices<i1...>, const T (&v2)[N-M], indices<i2...>) : value{v1[i1] ..., v2[i2] ...} {} public: T value[N]; };
のようにすると配列をコンパイル時に結合してみたりと、テンプレートメタプログラミングの幅が広がります。 もっとも、そこまで高度なメタプログラミングをしなければならないのなら、わざわざ車輪を再発明しなくても素直にSproutライブラリなりCELなりを使った方がずっと良いと思いますが。