ZOZOMATのクロスプラットフォーム3D

1720_1140_2

ZOZOMATとは何でしょうか?オンラインで靴を購入する際に、サイズが合わないという問題を解決する仕組みです。1台のスマートフォンと紙製のZOZOMATだけで、正確に足のサイズを測れます。足をスキャンすると、高精度の3Dモデルが生成されます。最適なサイズの靴も表示されるので、すぐに靴を購入できます。

zozomat_pr_001

こんにちは!ZOZOテクノロジーズの@kapsy1312です。ZOZOMATプロジェクトの一員として、スキャン結果を3D空間に表示するビューの開発を担当しました。プロトタイプでは、Appleの標準3Dフレームワーク、SceneKitを使用していました。しかし、全く同じ機能とデザインをAndroidで再現するにはコストがかかるため、さらに適切なソリューションを検討しました。

この記事では、スキャン結果の3DビューをAndroidとiOSデバイス向けに開発した際の課題と解決策を説明します。プラットフォーム依存のシーングラフフレームワークではなく、C++とOpenGLを選択した理由も説明します。付属のサンプルプロジェクトも用意してあり、後半で解説します。

スキャン結果の3Dビュー

zozomat_renderer_animation_001

このようなスキャン結果の3Dビューには、次の設計要件がありました。
  • iOS 10以上とAndroid 5以上のサポート
  • 遠近感が出ないように、足のメッシュを正投影で描画
  • 足の寸法を表示するラベルとその測定値をビルボード(常にカメラの方向を向く動き)で描画
  • 測定した箇所を正確に示す寸法線
  • アンビエントオクルージョン効果
  • カメラの初期位置を正確に指定
  • フェードと回転の初期アニメーション
  • ピンチイン・ピンチアウト、ドラッグによる回転、ダブルタップによるカメラのリセット
さらに、いくつかの実装上の要件がありました。
  • 片足ずつスキャンするので、描画時に両方の足のメッシュを連結する(並べる)必要がある
  • 描画前にメッシュのデータを90度回転し、大きさを調整しなければならない
  • 足のメッシュは、OBJファイルと独自バイナリの両方の形式を読み込む必要がある

検討した選択肢

時間が限られていたため、iOSのプロトタイプにはAppleのSceneKitを選択しました。そのおかげでプロトタイプの開発は間に合わせることができました。しかし製品版としてリリースするには、SceneKitの様々な制限を回避する必要があり、さらに全く同じデザインをAndroidで再現するコストもかかることが分かりました。

そこで、次の点を考慮して、いくつかのソリューションを調査しました。
  • 設計要件を満たす柔軟性は不可欠
  • 3Dビューはプラットフォーム間で同一に見えるべき
  • 設計の変更要求への対応が容易

検討した選択肢は次のとおりです。

SceneKit / Sceneform

SceneKitには次の利点がありました。
  • 重要な3D機能が揃っており、迅速なプロトタイピングが可能
  • アンチエイリアシング、ビルボード機能、フレームタイミングも用意されている
  • 提供されているユーザータッチ入力とカメラコントロールはとても便利
ただし、これにはいくつかの制限もありました。
  • メッシュの3D変換、ラベルのビルボード、カメラの動作はAndroidで完全に複製する必要がある
  • デフォルトのカメラ機能にバグ:ダブルタップしてリセットしてもアニメーションが思い通りに動かない
  • メッシュを読み込んでから表示するまでに時間がかかる
  • Swiftを使用して座標、ベクトル、行列の操作をするのは面倒
  • OBJファイルからのメッシュの読み込みは遅く、扱いも面倒
  • アンビエントオクルージョン効果が綺麗ではなくて、バグもある
  • プロトタイプではSCNViewのdefaultCameraControllerを使っていたが、iOS 10では使用できないので、アニメーションのコードを書き直す必要があった

これらの問題を解決するためには、美しくないハックと試行錯誤が必要です。また、望ましい結果が得られるまでフレームワークの動作を検証し続ける必要もあります。例えば、いくつかのサンプルプロジェクトを試してみないと、アンビエントオクルージョンの問題はバグなのか確認できませんでした。

詳細を調べたところ、AndroidのSceneformは、同様の制限が含まれているように見えました。これは、フレームワークに依存する際の根本的な問題と考えられます。フレームワークは提供者側が意図したとおりに使用されると役に立ちます。しかし、提供者側が意図していない使い方の場合には、回避策は自分で一から実装するよりコストが高くなる可能性もあります。

クロスプラットフォーム共有のC++とOpenGL

クロスプラットフォーム共有のC++とOpenGLは、ゲーム業界でよく使われている開発のアプローチです。プラットフォーム固有の機能は抽象化されており、プラットフォーム自体はその抽象化された関数等に準拠しています。プラットフォームは抽象化コードを経由して共通のC++コードベースを呼び出したり、コールバックされたりします。コードのほとんどをC++に移植できる場合にはクロスプラットフォームの仕組みは非常に有効です。

iOSとAndroid(NDK)の両方にOpenGL ESのCヘッダーがあるため、直接レンダリング関数を呼び出せます。ただし、プラットフォーム固有のレンダラー(Metalなど)を呼び出すと、さらに多くの作業が必要となります。

クロスプラットフォームコードは、同じ機能の重複を避けられます。さらに、設計要件を実装するのに十分な細かい制御ができます。

C++を使用すると、他にも多くの利点があります。
  • メッシュの読み込みがより効率的になる
  • SIMD機能も簡単に使える
  • CPUのキャッシュに優しい処理も可能
  • メモリ効率の良い構造体で行列とベクトルを扱える
  • ポインター操作は簡単で一貫している
  • パブリックドメインのヘッダーのみのライブラリ(stbなど)を活用できる
ただし、次の欠点もあると考えられます。
  • 柔軟性は最も高いが、作業量が増加する
  • 多くの機能は自分で実装する必要がある
  • 他のソリューションと比較して学習コストが高い
  • C++には不必要な機能があるため、コードガイドラインが不可欠
  • 経験の浅い開発者がミスを犯しやすい
  • シェーダーコードのデバッグが難しい

Unity

Unityはクロスプラットフォームのゲーム開発システムです。ソフトウェア自体は大きいのですが、携帯端末から高性能のゲーム用PC及びコンソールまで、幅広い複数の環境の開発が可能です。

ゲーム開発には向いていますが、ZOZOMATの開発要件を考えるとほんの一部しか必要ではありません。リアルタイムの物理シミュレーション、パーティクルシステム、VR、スクリプト、アニメーション等の必要性はゼロです。ZOZOMATでのユースケースが単純であることを考えると、学習コストがかなり高くなります。

Webアプリケーション

three.jsなどのシーングラフフレームワークを使用して、ネイティブのWebビューに3Dを表示するという方法です。

JavaScriptのWebアプリケーションを使用すると、次の利点があります。
  • 単一のコードベースが可能になる
  • JavaScript自体は割と簡単
  • three.jsだと、3D知識がなくても開発ができる
  • Webアプリケーションなので、変更する場合は再ビルド、再リリースが不必要
しかし、いくつか難点もありそうでした。
  • JavaScript側にメッシュデータを渡すにはASCIIの文字列でデータ量が多くなって、メッシュを表示するまで時間がかかる
  • メッシュバイナリの読み込みには、C++インタフェースが必要
  • JavaScriptは高水準言語であり、パフォーマンスの問題を引き起こす可能性がある

実はこの方法は、ZOZOSUITのスキャン結果の3Dビューに使用しました。クロスプラットフォームを実現できましたが、パフォーマンスと品質の問題がありました。読み込み時間が遅く、iOSアプリのクラッシュバグが発生する場合もありました。原因は、あるiOSバージョンのUIWebViewのWebGL実装にあったので、その原因にたどり着くのも非常に困難でした。同じ状態を繰り返さないように、このWebアプリケーションの方法は避けた方がいいと考えました。

「クロスプラットフォーム共有のC++とOpenGL」を選択

いろいろな選択肢を検討した結果、完璧な解決策は存在しないと気がつきました。すべてのオプションには利点と欠点があり、その多くは開発が始まらないと気づかないと思います。

その上で、クロスプラットフォーム共有のC++とOpenGLが最善のアプローチであると判断しました。

SceneKitとSceneformをそれぞれ実装すると、C++で共有するよりも開発コストが高くなりそうです。C++とOpenGL ESだとコードが1か所で管理でき、設計要件を満たすための好ましくないハックも必要なくなります。

テストアプリを作成し、設計要件が最小コストで実現できることを確認しました。さらに、より迅速に3Dビューの開発サイクルを繰り返せるように、macOS版も開発しました。iOS版よりアプリケーションのビルドの待ち時間が大幅に削減できました。 zozomat_tb_labels_000

Metalを採用すべきかの検討

AndroidではOpenGL ES、iOSではMetalを使用すると、両方をサポートする抽象化レイヤーがさらに必要になります。OpenGL ESはiOSおよびmacOSでは非推奨になっていますが、今回はコストと共通化の観点からOpenGL ESを使用しました。

パフォーマンスを追求しているアプリを開発する場合はMetalをサポートする抽象化レイヤーはオススメです。将来的に実装する計画はありますが、特に共通のシェーダー言語がないことを考えると、最初のバージョンとしてはコストが高かったです。

ZOZOMATのクロスプラットフォーム設計

ここまででクロスプラットフォームソリューションを選んだ理由を説明したので、次は実装の詳細について説明します。前述したように、iOS、macOS、Androidのサンプルプロジェクトがあるので、プロジェクトの内容を詳しく解説していきます。

zozomat_cross_platform_implementation

各プラットフォームには、独自のプラットフォームレイヤーが必要です。これは、プラットフォームと互換性のある言語で書かれています。C言語と対話できる外部関数インタフェース(Foreign Function Interface/FFI)をサポートする必要があります。Swift(iOS)やKotlin(Android JNI)はFFI機能を持つのですが、iOSやmacOS環境ではよりC言語と互換性が高いObjective-Cのほうが好ましいです。

処理の流れを解説します。
  • プラットフォーム抽象化レイヤー(platform abstraction layer)は、ヘッダーファイルにインタフェースを定義します。プログラムが正しく動作するためにプラットフォームレイヤー(platform layer)は抽象化レイヤーのインタフェースに従わなければなりません。
  • プラットフォームレイヤーは特有のフレームワーク(例えばNSFileManager、AAssetManager等)を内部的に使用し、プラットフォーム抽象化レイヤーのインタフェースに準拠します。
  • プラットフォームに依存しないレイヤー(platform independent layer)は、共有のC++が含まれています。このコードはプラットフォーム抽象化レイヤー(platform abstraction layer)の抽象化された関数なども従わなければなりません。

コードの大部分は、プラットフォーム依存しないレイヤー(platform independent layer)に収まる必要があります。そうでなければ、クロスプラットフォーム設計を選ぶ理由はほとんどありません。ZOZOMATとサンプルプロジェクトの場合、3Dのデータ変換とOpenGL ESレンダリングの呼び出しはすべて、プラットフォームに依存しません。

なお、プラットフォームごとにFFIを設定する方法の解説はこの記事の目的ではないので割愛します。iOSの場合は非常に簡単です。Android環境のJNIとNDKという機構の設定には Android NDK environment のドキュメントが参考になります。

クロスプラットフォーム関数の例

これは、サンプルプロジェクトに掲載している、クロスプラットフォームのinitコールバック関数の基本的な例です。このコードの例は、より分かりやすくするために簡略化しています。

// ztr_platform_abstraction_layer.h

typedef struct ztr_platform_api_t
{
  platform_open_file *openFile;

} ztr_platform_api_t;

#define ZTR_INIT(name) void name(ztr_platform_api_t *platform)
ZTR_INIT(ztrInit);
// RenderView.m(iOS)

#import "ztr_platform_abstraction_layer.h"

static ztr_platform_api_t g_platform;

- (void) setup
{
  g_platform.openFile = openFile;
  ztrInit (&g_platform);
}
// RenderLib_ndk.cpp (Kotlin側から呼ぶAndroid JNIインタフェース)

#import "ztr_platform_abstraction_layer.h"

static ztr_platform_api_t g_platform;

extern "C" JNIEXPORT void JNICALL
Java_com_zozo_ztr_1android_RenderLib_init(JNIEnv* env,
                                          void *reserved,
                                          jobject assetManager)
{
  g_platform.openFile = openFile;
  ztrInit (&g_platform);
}
// ztr_platform_independent_layer.cpp

#import "ztr_platform_abstraction_layer.h"

static ztr_platform_api_t *g_platform;

ZTR_INIT (ztrInit)
{
  // プラットフォームAPIのポインターを保存する
  g_platform = platform;

  // ここへプラットフォームに依存しない初期化コードを書く
  // ...
  // ...
  // ...
}

共有のC++コードで初期化関数を実行したい場合はztr_platform_abstraction_layer.hの初期化関数のシグネチャーztrInit()が定義されているので、各プラットフォームで必要に応じてこの関数を呼び出し、必要なデータを渡すと、プログラムが正しく起動されます。

iOSの場合はRenderView.msetup関数から呼び出されます。Androidの場合はRenderLibinit関数から呼び出されます。厳密に言うと、AndroidはJNIインタフェースを通じてRenderLib_ndk.cppの中間関数を呼び出す、という流れです。この時、プラットフォームへのポインターも保存します。

プリプロセッサマクロを使用して関数のシグネチャを定義するという考え方は、Handmade Heroからのものであることに注意してください。これは、抽象化レイヤー(platform abstraction layer)関数のシグネチャの引数の重複を回避するためです。これを使わないと各プラットフォームレイヤーにシグネチャーを複製しないといけません。引数の順番、数等を変更する場合も各プラットフォームの変更が必要です。プリプロセッサマクロを使用したら1つの変更で済むのでとても便利です。

プラットフォーム機能の呼び出し

ztr_platform_independent_layer.ccがiOSとAndroidのプラットフォームレイヤー(platform layer)に存在するファイルを開く例を紹介します。

// ztr_platform_abstraction_layer.h

#define PLATFORM_OPEN_FILE(name) ztr_file_t name(const char *fileName)
typedef PLATFORM_OPEN_FILE(platform_open_file);
// RenderView.m(iOS)

PLATFORM_OPEN_FILE (openFile)
{
  ztr_file_t result = {};
  NSArray *components =
    [[NSString stringWithUTF8String:fileName]
        componentsSeparatedByString:@"."];
  if (components.count == 2)
  {
    NSString *fileNameBase =
      [NSString stringWithFormat:@"res/%@", components[0]];
    NSURL *fileUrl =
      [[NSBundle mainBundle] URLForResource:fileNameBase
                              withExtension:components[1]];

    NSFileManager *manager = [NSFileManager defaultManager];
    NSData *data = [manager contentsAtPath:fileUrl.path];

    if (data)
    {
      result.data = (void *) data.bytes;
      result.dataSize = (unsigned int) data.length;
    }
  }

  return (result);
}
// RenderLib_ndk.cpp(Android JNI interface)

PLATFORM_OPEN_FILE(openFile)
{
  assert (fileName != NULL);
  ztr_file_t result = {};
  AAsset *asset = AAssetManager_open (asset_manager,
                                      fileName,
                                      AASSET_MODE_STREAMING);
  assert (asset != NULL);
  if (asset != NULL)
  {
    result.data = (void *) AAsset_getBuffer (asset);
    result.dataSize = AAsset_getLength (asset);
  }

  return (result);
}
// ztr_platform_independent_layer.cc

static mesh_t *
loadObj (const char *fileName)
{
  ztr_file_t file = g_platform->openFile (fileName);
  if (file.data != NULL)
  {
    // OBJファイル内容を読み込む
  }
}

プラットフォームに依存しないレイヤー、ztr_platform_independent_layer.ccloadObj()関数では、3Dメッシュを読み込むためOBJファイルを開かなければなりません。ファイル名は認識していますが、ファイルを開くのはプラットフォーム自体に任せるしかありません。iOSとAndroidは異なる方法でファイルにアクセスするため、それぞれのプラットフォームレイヤーに依存する必要があります。

iOSアプリ内のファイルは、アプリケーションの内部ディレクトリ構造の特定の場所に存在するバンドルというものに収まっています。Androidの場合は、APK(基本的にはzipファイル)からファイルを抽出する必要があります。ztr_platform_independent_layer.ccは各プラットフォームのファイルを開く方法は何も知らず、むしろ、知るべきではありません。

RenderView.mRenderLib_ndk.cppztr_platform_abstraction_layer.hで定義しているインタフェースに従い、ztr_file_tの構造体を返しさえすれば、プラットフォーム間の差を無くすことができます。

これは、グローバル構造体g_platformが使用される場所です。ファイルを開く関数を呼び出すには、プラットフォームへの参照が必要です。g_platformは初期化関数で保存されています。

クロスプラットフォームOpenGL ES

// ztr_platform_abstraction_layer.h

typedef struct ztr_hid_t
{
  float mouseX;
  float mouseY;

  int mouseDown;
  int mouseTransition;
  int doubleTap;

  int pinchZoomActive;
  int pinchZoomTransition;
  float pinchZoomScale;

} ztr_hid_t;

// MARK: Platform callback functions

#define ZTR_INIT(name) void name(ztr_platform_api_t *platform)
ZTR_INIT(ztrInit);

#define ZTR_DRAW(name) void name(ztr_mem_t *mem, ztr_hid_t hid)
ZTR_DRAW(ztrDraw);

#define ZTR_RESIZE(name) void name(ztr_platform_api_t *platform, \
                                   int w, int h)
ZTR_RESIZE(ztrResize);

クロスプラットフォームOpenGL ESを実装するのに、3つのコールバック関数を抽象化する必要があります。初期化関数、描画関数(各フレームごとに呼ばれる)、および画面のサイズを変更する関数です。

各プラットフォームのOpenGL ESセットアップについてはここでは説明しないので、興味のある方はぜひ、サンプルプロジェクトを参考にしてください。

iOSの描画関数は次のようになります。
// RenderView.m(iOS)

- (void) drawView
{
  [[self openGLContext] makeCurrentContext];
  CGLLockContext ([[self openGLContext] CGLContextObj]);

  ztrDraw (0, g_hid);
  g_hid.mouseTransition = 0;

  CGLFlushDrawable ([[self openGLContext] CGLContextObj]);
  CGLUnlockContext ([[self openGLContext] CGLContextObj]);
}

プラットフォームレイヤー、RenderView.mが特有のOpenGL ESメソッドを呼び出してから、抽象化されてる関数ztrDrawを呼び出します。ユーザーのタッチ操作データも渡します。

Androidの場合、セットアップはより複雑ですが、最終的にはRenderViewクラスがRenderLib.draw()を呼び出します。

// RenderView.kt

inner class
Renderer(val assetManager: AssetManager) : GLSurfaceView.Renderer {

  var view: RenderView? = null
    var onSurfaceCreatedClosure: ((view: RenderView) -> Unit)? = null

    override fun onDrawFrame(gl: GL10) {
      RenderLib.draw(
          this@RenderView._mouseDown,
          this@RenderView._mouseDownUp,
          this@RenderView._mouseX,
          this@RenderView._mouseY
          )
    }
}

RenderLib.draw()はJNIという機構を経由して、Java_com_zozo_ztr_1android_RenderLib_drawのC++関数で、ztrDrawを呼び出します。いくら文句を言ってもAndroidのJNIとはそういうものです。

// RenderLib_ndk.cpp(Android JNIインタフェース)

extern "C" JNIEXPORT void JNICALL
Java_com_zozo_ztr_1android_RenderLib_draw(JNIEnv* env, jobject obj,
    jint mouseDown, jint mouseDownUp, jint x, jint y) {

  hid.mouseDown = static_cast<int> (mouseDown);
  hid.mouseTransition = static_cast<int> (mouseDownUp);
  hid.mouseX = static_cast<int> (x);
  hid.mouseY = static_cast<int> (-y);

  ztrDraw (0, hid);

  hid.mouseTransition = 0;
}

GLSurfaceViewやJNIなしでOpenGL ESを呼び出すことも可能であり、そのようなアプローチはより綺麗でパフォーマンスも高くすることができます。

クロスプラットフォームのレンダリング

お馴染みのStanford Bunnyをレンダリングするための処理を紹介します。

bunny_sample_project

初期化関数は以下のようになります。
// ztr_platform_independent_layer.cc

ZTR_INIT (ztrInit)
{
  g_platform = platform;
  g_scene.shaderCount = 0;

  // OpenGL ESを設定する
  GLint m_viewport[4];
  glGetIntegerv (GL_VIEWPORT, m_viewport);
  glEnable (GL_CULL_FACE);
  glEnable (GL_BLEND);
  glBlendEquationSeparate (GL_FUNC_ADD,GL_FUNC_ADD);
  glBlendFuncSeparate (GL_ONE, GL_ONE_MINUS_SRC_ALPHA,
                       GL_ONE, GL_ONE_MINUS_SRC_ALPHA);

  // シェーダープログラムをテキストファイルから読み込む
  assert (g_scene.shaderCount < MAX_SHADERS);
  g_scene.objectShader = g_scene.shaders + g_scene.shaderCount++;
  g_scene.objectShader->program =
    LoadShaders (shadingVersion,
                 (char *) "shaders/object_vert.glsl",
                 (char *) "shaders/object_frag.glsl");
  g_scene.objectShader->elementType = GL_TRIANGLES;
  glUseProgram (g_scene.objectShader->program);

  // 一度、シェーダーの色を設定する
  GLint objectColorLoc =
    glGetUniformLocation (g_scene.objectShader->program, "objectColor");
  glUniform3f (objectColorLoc, 255.f/255.99f, 174.f/255.99f, 82.f/255.99f);
  GL_CHECK_ERROR ();

  // すべての構造体の初期値を設定する関数を呼び出す
  InitScene (&g_scene);
  InitCam (&g_scene.camera);
  InitMouse (&g_scene.mouse);

  // Stanford Bunny メッシュを読み込む
  // loadObj関数はメッシュのVAO、VBO、EBO、バッファを初期化する
  // GPU上に頂点とインデックスのデータを保存する領域を確保している
  mesh_t *bunnyMesh = loadObj ("bunny_vn.obj");
  float S = 1.f;
  bunnyMesh->S = HMM_Scale (HMM_Vec3 (S, S, S));
  bunnyMesh->R = HMM_Rotate (0.f, HMM_Vec3 (1,0,0));
  bunnyMesh->T = HMM_Translate (HMM_Vec3 (0,0,0));
  bunnyMesh->shader = g_scene.objectShader;

  g_scene.ready = 1;
  g_scene.animatingIntroFade = 1;
}
loadObj関数の内容は以下のようになります。
// ztr_platform_independent_layer.cc

  static mesh_t *
loadObj (const char *fileName)
{
  // (省略した)tinyobj_parse_obj で頂点データを読み込む
  // ...
  // ...
  // ...

  // VAO、VBO、EBO、を初期化する
  glGenVertexArrays (1, &mesh->VAO);
  glGenBuffers (1, &mesh->VBO);
  glGenBuffers (1, &mesh->EBO);

  // VAOを紐づけるとVBOとEBOを設定できる
  glBindVertexArray (mesh->VAO);

  // VBOバッファーメッシュの頂点を割り当てる
  glBindBuffer (GL_ARRAY_BUFFER, mesh->VBO);
  glBufferData (GL_ARRAY_BUFFER,
                mesh->verticesCount*sizeof (vertex_t),
                mesh->vertices,
                GL_STATIC_DRAW);

  // EBOバッファーメッシュのインデックスを割り当てる
  glBindBuffer (GL_ELEMENT_ARRAY_BUFFER, mesh->EBO);
  glBufferData (GL_ELEMENT_ARRAY_BUFFER,
                mesh->indicesCount*sizeof (GLushort),
                mesh->indices,
                GL_STATIC_DRAW);

  // メモリ上の頂点構造体(vertex_t)のポジションの位置を指定する
  glEnableVertexAttribArray (0);
  glVertexAttribPointer (0, 3, GL_FLOAT, GL_FALSE, sizeof (vertex_t),
                         (GLvoid *) offsetof (vertex_t, position));

  // メモリ上の頂点構造体(vertex_t)のノーマルの位置を指定する
  glEnableVertexAttribArray (1);
  glVertexAttribPointer (1, 3, GL_FLOAT, GL_FALSE, sizeof (vertex_t),
                         (GLvoid *) offsetof (vertex_t, normal));

  // glBindVertexArray に0を指定するとVAOが解放される
  glBindVertexArray (0);
}
各フレームごとに呼び出されている描画関数は以下のようになります。
// ztr_platform_independent_layer.cc

ZTR_DRAW (ztrDraw)
{
  // 白色で塗りつぶす
  glClearColor (1.f, 1.f, 1.f, 1.f);
  glClear (GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);

  if (g_scene.ready)
  {
    camera_t *cam = &g_scene.camera;
    mouse_t *mouse = &g_scene.mouse;

    // (省略)タッチ操作の変化でカメラの位置を計算する
    // ...
    // ...
    // ...

    // 射影行列とビュー行列を作成する
    float ratio =
      (float) g_scene.screenDims.Y/(float) g_scene.screenDims.X;
    float orth = cam->orthScale;
    hmm_mat4 proj = HMM_Orthographic (-orth, orth,
                                      -ratio*orth, ratio*orth,
                                      CAM_NEAR, CAM_FAR);
    hmm_mat4 view = HMM_LookAt (cam->pos,
                                CAM_LOOKAT_CENTER,
                                CAM_LOOKAT_UP);

    // 射影行列とビュー行列をシェーダープログラムに渡す
    for (int i=0 ; i<g_scene.shaderCount ; i++)
    {
      shader_t *shader = g_scene.shaders + i;

      glUseProgram (shader->program);

      GLuint viewMatrixLoc =
        glGetUniformLocation (shader->program, "view");
      glUniformMatrix4fv (viewMatrixLoc, 
                          1,
                          GL_FALSE,
                          &view.Elements[0][0]);
      GL_CHECK_ERROR ();

      GLuint projMatrixLoc =
        glGetUniformLocation (shader->program, "projection");
      glUniformMatrix4fv (projMatrixLoc,
                          1,
                          GL_FALSE,
                          &proj.Elements[0][0]);
      GL_CHECK_ERROR ();
    }

    // メッシュの位置、回転情報をシェーダープログラムに渡す
    for (int i=0 ; i<g_scene.meshCount ; i++)
    {
      mesh_t *mesh = g_scene.meshes + i;
      shader_t *shader = mesh->shader;
      glUseProgram (shader->program);

      mesh->model = mesh->T*mesh->R*mesh->S;

      GLuint rotateMatrixLoc =
        glGetUniformLocation (shader->program, "rotate");
      glUniformMatrix4fv (rotateMatrixLoc,
                          1,
                          GL_FALSE,
                          &mesh->R.Elements[0][0]);

      GLuint modelMatrixLoc =
         glGetUniformLocation (shader->program, "model");
      glUniformMatrix4fv (modelMatrixLoc,
                          1,
                          GL_FALSE,
                          &mesh->model.Elements[0][0]);

      // VAOを紐づける
      glBindVertexArray (mesh->VAO);
      GL_CHECK_ERROR ();

      // シェーダープログラムを経由して、三角形を描く
      glDrawElements (shader->elementType,
                      mesh->indicesCount,
                      GL_UNSIGNED_SHORT,
                      0);
      GL_CHECK_ERROR ();

      glBindVertexArray (0);
      GL_CHECK_ERROR ();
    }

    // 懐中電灯の効果のためカメラの位置をシェーダープログラムに渡す
    glUseProgram (g_scene.objectShader->program);
    GL_CHECK_ERROR ();

    hmm_vec3 lightPos = HMM_Vec3 (1,1,2);
    GLint lightPosLoc =
      glGetUniformLocation (g_scene.objectShader->program, "lightPos");
    glUniform3f (lightPosLoc, lightPos[0], lightPos[1], lightPos[2]);
    GL_CHECK_ERROR ();
  }
}

ここではOpenGLについてほんの僅かしか説明しておらず、シェーダーすら紹介していません。3Dラスタグラフィックスの基本については、いくつかの分かりやすいチュートリアル等が存在しているので、そちらを参考にしてください。

もう1つ、解説しておかないと分かりづらい箇所があります。glDrawElements()のようなOpenGL ES関数はどこから来ているのでしょうか?それらもプラットフォームに抽象化されるべきではないか、と思われる方もいるかもしれません。

厳密に言えば、そうすべきです。ただし、どちらのプラットフォームでも同じであり、パフォーマンスの面で考えると直接呼び出した方が楽です。たとえばAppleのMetalレンダリングAPIを使用する場合は、レンダラーの抽象化も必要となります。

まとめ

ZOZOMATを発表してからしばらく時間が経ち、クロスプラットフォームの共有C++とOpenGL ESを使用したことは正解だと思いました。サンプルプロジェクトのように統一感を保つことができ、設計要件を満たして、開発コストも節約できました。

しかし、クロスプラットフォームコードは決して万能なソリューションではありません。いくら利点があっても、すべてのプロジェクトで実装することが賢明であるとは限りません。3Dの表示方法が簡単な場合や、迅速にプロトタイプが必要な場合は、SceneKitやSceneformで十分だと思います。

プラットフォーム抽象化レイヤーに追加された関数は、プラットフォームでの実装コストが発生します。コストが高まると、収穫逓減点を超える可能性があります。

プロジェクトで複数のプラットフォームのサポートが必要で、コードの大部分を共有できることが明確である場合は、少し時間をかけてクロスプラットフォームソリューションを実装する価値があると思います。

ZOZOテクノロジーズでは、iOSエンジニアを募集しています。興味のある方はこちらからご応募ください!

tech.zozo.com

カテゴリー