
はじめに
こんにちは、ZOZO New Zealandの中岡です。普段はZOZOMAT/ZOZOGLASSの運用・保守や計測技術を使った新規事業の開発をしています。
目次
ZOZOMATとは
オンラインで靴を購入する際に、サイズが合わないという問題を解決する仕組みです。1台のスマートフォンと紙製のZOZOMATだけで、正確に足のサイズを測れます。足をスキャンすると、高精度の3Dモデルが生成されます。最適なサイズの靴も表示されるので、すぐに靴を購入できます。


ZOZOMATの構成
ZOZOMATの機能は社内ライブラリとして開発されており、ZOZOTOWNに組み込まれています。以下は依存関係の一部です。ZOZOMATの機能を提供しているライブラリはZOZOMATフレームワークと呼ばれており、フレームワークはさらに計測結果の3Dモデルの表示するためのZOZOMAT Rendererに依存しています。
本記事ではタイトルにもあるとおり、そのZOZOMAT RendererのOpenGL ESからMetalへの移行についてお話しします。

移行の背景
足の3Dモデルのレンダリング使っているOpenGL ESはiOS12でDeprecatedになっており、将来的に利用できなくなる可能性がありAppleもMetalへの移行を推奨しています。
検討したアプローチ
ZOZOMAT Rendererは以下の図にあるようにクロスプラットフォームに対応しています。そのため、単純にプラットフォーム非依存レイヤーの中のOpenGL ESをMetalに書き換えることはできません。

移行するためには引き続きAndroidをサポートしつつiOSでのみMetalで動作するようにしなければいけません。そのためのアプローチは大きく分けて2つありました。
- bgfxのようなMetalをバックエンドとして利用可能なクロスプラットフォームのレンダリングライブラリに移行する
- バッファ作成や描画処理といったグラフィックスAPIを呼び出す処理を抽象化し、プラットフォームごとにOpenGL ES/Metalを呼び出す
最終的に、2番目のアプローチを選択しました。その理由は以下の通りです。
- Android側の実装に極力影響を与えず、最小限の工数で進められる
- 描画対象が比較的シンプルな3Dモデルであり、外部ライブラリの導入に見合うメリットが少なかった
移行後の構成
以下は移行後の簡単な構成図です。プラットフォーム非依存レイヤー(MVP行列の管理・シーン管理などのコアロジック)から、実際の描画呼び出し部分を切り出しました。そして、Cヘッダーで定義した抽象インタフェースを経由しOpenGL ES/Metalの各バックエンド実装に振り分けるといった構成です。

レンダリングバックエンドの抽象化
グラフィックスAPIを使ったレンダリングには主に以下のようなステップがあり、Cヘッダーの抽象インタフェースはこれらの処理をするメソッドがステップごとに定義されています。
| ステップ | OpenGL ES | Metal |
|---|---|---|
| 1. シェーダーの読み込み | - GLSL ソースをコンパイル・リンク - プログラムオブジェクトを生成 |
- MSL ソースをライブラリ化 - MTLRenderPipelineStateを生成 |
| 2. バッファの作成 | - VBO/EBO を生成してバインド | - MTLBuffer を生成 |
| 3. 描画 | - プログラムをアクティブ化してユニフォーム設定 - glDrawElementsを実行 |
- コマンドバッファ/エンコーダを作成 - 頂点/インデックスをエンコーダにセット - drawIndexedPrimitives を実行 |
ポインタによる抽象化
MetalではバックエンドレイヤーでMTLBufferやMTLRenderPipelineStateの生成をするためにMTLDeviceが必要です。また、各フレームでdrawIndexedPrimitivesを呼ぶ際にMTLRenderCommandEncoderも必要です。これらのオブジェクトはiOS側で生成しプラットフォーム非依存レイヤーを経由してバックエンドレイヤーに渡さなければいけませんでした。この際にMetal固有の型を隠蔽するためにポインタを使います。
以下は簡単なサンプルコードです。context_tにMetal固有の型を定義してその型のポインタをプラットフォーム非依存レイヤーを経由してバックエンドレイヤーに渡し型キャストして利用します。
// context.h #import <Metal/Metal.h> typedef struct context_t { id<MTLDevice> metalDevice; id<MTLRenderCommandEncoder> currentRenderCommandEncoder; } context_t;
// ZMRMetalView.m // プラットフォーム(iOS)側 #import "context.h" @interface ZMRMetalView () { id<MTLCommandQueue> _commandQueue; context_t context; // 省略 } @end @implementation ZMRMetalView // 省略 - (void) setup { context.metalDevice = MTLCreateSystemDefaultDevice(); _commandQueue = [_device newCommandQueue]; // 省略 // ここでcontextの参照をプラットフォーム非依存レイヤーに渡す zmrInit(&context); } // 毎フレーム呼ばれる - (void) drawView:(id)sender { id<MTLCommandBuffer> commandBuffer = [_commandQueue commandBuffer]; MTLRenderPassDescriptor *renderPassDescriptor = [MTLRenderPassDescriptor renderPassDescriptor]; // 省略 // MTLRenderCommandEncoderの生成しcontextに渡す context.currentRenderCommandEncoder = [commandBuffer renderCommandEncoderWithDescriptor:renderPassDescriptor]; }
// プラットフォーム非依存レイヤー void zmrInit(void *context) { // バックエンド側にcontextをそのまま渡す initBackend(context); }
#import "context.h" static context_t *metalContext void initBackend(void *context) { // 汎用ポインタからキャスト metalContext = (context_t *)context; }
移行の際に当たった課題と工夫点
Objective-CとC/C++のメモリ管理の違い
Objective-CはARC(Automatic Reference Counting)を使用しており、C/C++は手動でメモリ管理します。今回の移行では、既存のクロスプラットフォーム設計を維持するため、MetalオブジェクトをCの構造体に保持する必要がありました。この際、ARCと手動メモリ管理の境界で適切なブリッジングをします。
CFBridgingを使ったリソース管理
MetalオブジェクトをCの構造体で管理する際の参照カウントの変化は以下です。
作成時(参照カウント+1):
CFBridgingRetainでARC管理からC構造体の手動管理に移行newBufferWithBytes:などでMetalオブジェクトを作成(参照カウント=1)CFBridgingRetainで参照カウントを+1し、C側で保持(参照カウント=2)- ARC管理下のローカル変数がスコープを抜けると-1(参照カウント=1、C側のみが保持)
使用時(参照カウント変化なし):
__bridgeで一時的にObjective-CオブジェクトとしてObjective-C++で参照- 参照カウントは変化せず、単にキャストのみ実行
破棄時(参照カウント-1):
__bridge_transferで手動管理からARC管理に戻して自動解放- C側の所有権をARCに移譲(参照カウントは変化しない)
- ARCがスコープ終了時に自動的に-1して解放(参照カウント=0)
// Cの構造体でリソースハンドルを管理 typedef struct { uint64_t vertexBufferHandle; uint64_t indexBufferHandle; // その他のメンバー... } RenderResource; // Metalリソースの作成 void setupRenderingResources(RenderResource *resource) { // Metalバッファを作成(ARCで管理) id<MTLBuffer> vertexBuffer = [device newBufferWithBytes:vertices length:vertexDataSize options:MTLResourceStorageModeShared]; // CFBridgingRetainでCの構造体にリソースを保存 resource->vertexBufferHandle = (uint64_t)CFBridgingRetain(vertexBuffer); } // Objective-C++側でMetalリソースを使用 void drawFrame(RenderResource *resource) { // __bridgeでハンドルをMetalオブジェクトに戻す(所有権は移さない) id<MTLBuffer> buffer = (__bridge id<MTLBuffer>)(void *)resource->vertexBufferHandle; [currentEncoder setVertexBuffer:buffer offset:0 atIndex:0]; // 描画処理... } // リソースのクリーンアップ void cleanupRenderingResources(RenderResource *resource) { // __bridge_transferで手動管理からARCに所有権を戻す id<MTLBuffer> buffer = (__bridge_transfer id<MTLBuffer>)(void *)resource->vertexBufferHandle; // bufferはここでスコープを抜けてARCによって自動的に解放される resource->vertexBufferHandle = 0; }
座標系の違い
OpenGLESとMetalではNDC(正規化デバイス座標)のZ座標の範囲が異なるため、同じ投影行列を使用する場合は注意が必要です。もともとOpenGLESの座標系に従った行列が渡されるため、Metalでは以下のように頂点シェーダーでZ軸の変換をする処理を加えました。
GLSL
// 頂点シェーダー(GLSL) layout (location = 0) in vec3 position; uniform mat4 projection; uniform mat4 view; uniform mat4 model; void main() { gl_Position = projection * view * model * vec4(position, 1.0); // OpenGLはそのままNDC座標を使用 }
MSL
// 頂点シェーダー(MSL) vertex float4 foot_vertex(float3 position [[attribute(0)]], constant float4x4 &view [[buffer(1)]], constant float4x4 &projection [[buffer(2)]], constant float4x4 &model [[buffer(3)]]) { float4 pos = float4(position, 1.0); float4 clipPos = projection * view * model * pos; // OpenGLのNDC Z座標 [-1,1] をMetalの [0,1] に変換 float newZ = (clipPos.z * 0.5) + 0.5; return float4(clipPos.xy, newZ, clipPos.w); }
まとめ
本記事ではZOZOMAT RendererのOpenGL ESからMetalへの移行について、既存のクロスプラットフォーム設計を維持するための抽象化アプローチやその際の注意点を解説しました。
また現在の実装では、GLSLとMSLのシェーダーが二重管理となっています。そのため、将来的にはSPIRV-Crossのようなシェーダー変換ツールの導入を検討しています。SPIRV-Crossを使用することで、単一のシェーダーソースからOpenGL(GLSL)とMetal(MSL)両方のシェーダーを自動生成できるようになり、シェーダーの一元管理が可能になります。
ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。