Saturday, February 28, 2015

Visual StudioとAndroid StudioでOpenGLを使ったマルチプラットフォーム開発

実際に構築してみました。ここにコミットしています。
ソースを共用しながら、AndroidとWindowsの両方で動かせるようにしてみました。C++を主に使うのでNDKも入れておきます。

Android StudioでassetsフォルダやC++のソースを階層の浅い場所に移動


Androidのassetsフォルダにテクスチャやシェーダーを配置します。ただ、eclipse時代と違ってAndroid Studioはデフォルトで app/src/main/assetsとすごく深い場所にあります。これでは不便なので、build.gradle に以下のように書いて使いやすい場所を指定します。(同時にC++のソースも浅い場所に移動しています)

android {
sourceSets {
main.jni.srcDirs = ['../../cpp']
main.assets.srcDirs = ['../../assets']
}
}
view raw build.gradle hosted with ❤ by GitHub

main.assets.srcDirsはassetsを置く場所、main.jni.srcDirsはC++のファイルを置く場所です。'../../assets'のようにAndroid Studioのプロジェクトファイルより上位のフォルダも指定できることがわかりました。

Visual Studioからも同じソースやファイルを使うので、この任意のフォルダ位置を指定する機能は重宝します。


assetsフォルダをC++から使う


AndroidのassetsはC++から直接アクセスできないので、Javaを経由します。以下のようなヘルパークラスを作っておき、C++から使います。

public class Helper {
private static Context context;
public static void setContext(Context c) { context = c; }
public static byte[] loadIntoBytes(String fileName) {
AssetManager assetManager = context.getAssets();
try {
InputStream is = assetManager.open(fileName);
byte buf[] = new byte[is.available()];
is.read(buf);
return buf;
} catch (IOException e) {
}
return null;
}
view raw Helper.java hosted with ❤ by GitHub
const char* boundJavaClass = "common/pinotnoir/Helper";
void *LoadFile(const char *fileName, int* size)
{
jclass myview = jniEnv->FindClass(boundJavaClass);
jmethodID method = jniEnv->GetStaticMethodID(myview, "loadIntoBytes", "(Ljava/lang/String;)[B");
if (method == 0) {
return nullptr;
}
jobject arrayAsJObject = jniEnv->CallStaticObjectMethod(myview, method, jniEnv->NewStringUTF(fileName));
jbyteArray array = (jbyteArray)arrayAsJObject;
jbyte* byteArray = jniEnv->GetByteArrayElements(array, NULL);
jsize arrayLen = jniEnv->GetArrayLength(array);
void* ptr = calloc(arrayLen + 1, 1);
memcpy(ptr, byteArray, arrayLen);
if (size) {
*size = arrayLen;
}
jniEnv->ReleaseByteArrayElements(array, byteArray, 0);
return ptr;
}
Javaでは指定のファイル名でassetsフォルダから読みだしてバイト列にしてC++側に返します。C++側でJavaからバイト列をもらい、改めてcallocでメモリを割り当て直してコピーしています。callocでサイズを+1しているのは、テキストファイルの終端に'\0'を入れたいためです。


テクスチャをJava経由でC++から生成


Android機はGPUによって対応している圧縮テクスチャフォーマットが違うので面倒です。この対応は後で考える事とし、とりあえず手始めにjpgやpngなどをまず使えるようにしておきます。
AndroidではJavaから直接テクスチャを生成すると簡単です。assetsフォルダからBitmapFactory.decodeStreamでBitmapを生成し、更にBitmapからGLUtils.texImage2Dでテクスチャを生成します。JavaからC++に渡すのはGLuint型(Javaではint型)の生成済みのテクスチャを表すtexture nameだけなのでシンプルに実装できます。

class Helper {
...
public static int loadTexture(String s){
Bitmap img;
try {
img = BitmapFactory.decodeStream(context.getAssets().open(s));
} catch (IOException e) {
return -1;
}
int tex[] = new int[1];
GLES20.glGenTextures(1, tex, 0);
GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, tex[0]);
GLUtils.texImage2D(GL10.GL_TEXTURE_2D, 0, img, 0);
GLES20.glGenerateMipmap(GLES20.GL_TEXTURE_2D);
GLES20.glTexParameteri(GL10.GL_TEXTURE_2D, GL10.GL_TEXTURE_MIN_FILTER, GL10.GL_LINEAR_MIPMAP_LINEAR);
GLES20.glTexParameteri(GL10.GL_TEXTURE_2D, GL10.GL_TEXTURE_MAG_FILTER, GL10.GL_LINEAR);
GLES20.glTexParameteri(GL10.GL_TEXTURE_2D, GL10.GL_TEXTURE_WRAP_S, GL10.GL_CLAMP_TO_EDGE);
GLES20.glTexParameteri(GL10.GL_TEXTURE_2D, GL10.GL_TEXTURE_WRAP_T, GL10.GL_CLAMP_TO_EDGE);
GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, 0);
img.recycle();
return tex[0];
}
view raw Helper.java hosted with ❤ by GitHub
static GLuint LoadTextureViaOS(const char* name)
{
jclass myview = jniEnv->FindClass(boundJavaClass);
jmethodID method = method = jniEnv->GetStaticMethodID(myview, "loadTexture", "(Ljava/lang/String;)I");
if (method == 0) {
return 0;
}
return jniEnv->CallStaticIntMethod(myview, method, jniEnv->NewStringUTF(name));
}
view raw tex_map.cpp hosted with ❤ by GitHub

Windows版の対応


それぞれAndroid版と同名のローダを作ります。

LoadFileはassetsフォルダのファイルをfopenでファイルを読み込むだけです。
LoadTextureViaOSではGDI+を使うことで、アルファチャンネル付きの32bitのrawデータが得られます。ここからテクスチャを生成しています。

namespace Gdiplus {
using std::min;
using std::max;
}
#include <gdiplus.h>
#pragma comment(lib, "gdiplus.lib")
static GLuint LoadTextureViaOS(const char* name)
{
Gdiplus::GdiplusStartupInput gdiplusStartupInput;
ULONG_PTR gdiplusToken;
Gdiplus::GdiplusStartup(&gdiplusToken, &gdiplusStartupInput, nullptr);
WCHAR wc[MAX_PATH];
MultiByteToWideChar(CP_ACP, 0, name, -1, wc, dimof(wc));
Gdiplus::Bitmap* image = new Gdiplus::Bitmap(wc);
int w = (int)image->GetWidth();
int h = (int)image->GetHeight();
Gdiplus::Rect rc(0, 0, w, h);
Gdiplus::BitmapData* bitmapData = new Gdiplus::BitmapData;
image->LockBits(&rc, Gdiplus::ImageLockModeRead, PixelFormat32bppARGB, bitmapData);
std::vector<uint32_t> col;
col.resize(w * h);
for (int y = 0; y < h; y++) {
memcpy(&col[y * w], (char*)bitmapData->Scan0 + bitmapData->Stride * y, w * 4);
for (int x = 0; x < w; x++) {
uint32_t& c = col[y * w + x];
c = (c & 0xff00ff00) | ((c & 0xff) << 16) | ((c & 0xff0000) >> 16);
}
}
image->UnlockBits(bitmapData);
delete bitmapData;
delete image;
Gdiplus::GdiplusShutdown(gdiplusToken);
GLuint texture;
glGenTextures(1, &texture);
glBindTexture(GL_TEXTURE_2D, texture);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
glPixelStorei(GL_UNPACK_ALIGNMENT, 1);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, w, h, 0, GL_RGBA, GL_UNSIGNED_BYTE, &col[0]);
glGenerateMipmap(GL_TEXTURE_2D);
glBindTexture(GL_TEXTURE_2D, 0);
return texture;
}
view raw tex_man.cpp hosted with ❤ by GitHub
GDI+とOpenGLではRGBAの並びが違うのでビットシフトで置き換えています。
OpenGLは以前作ったWGLGrabberを使って関数ポインタを取得しています。


ネイティブクラスの設計


基本的に出来る限りC++で処理すると決めたので、onTouchEventを使ったタッチ等のイベントは全部JavaからC++に渡します。また、GLSurfaceViewを使うので、onDrawFrameからC++のレンダラーを呼び出すことになります。

注意すべきはJavaのスレッドが2つあることです。onTouchEventはUIスレッド、onDrawFrameはレンダースレッドです。

今回は簡単にするため、タッチの座標だけ保存しておいてonDrawFrameの中からタッチイベントもC++に渡します。こうすることでC++は常にレンダースレッドで実行されることが保証されます。

将来的にはマルチスレッドの同期取りはC++側に移動したほうが最適化しやすいかもしれません。


まとめ


OpenGLを使った開発はOpenGL関連のコードが大多数を占めるので、このようにプラットフォーム依存する場所を開発の始めに括っておくと以後のマルチプラットフォーム化が楽になります。

No comments:

Post a Comment