ZOZOMAT RendererにおけるOpenGL ESからMetalへの移行

ZOZOMAT RendererにおけるOpenGL ESからMetalへの移行

はじめに

こんにちは、ZOZO New Zealandの中岡です。普段はZOZOMAT/ZOZOGLASSの運用・保守や計測技術を使った新規事業の開発をしています。

目次

ZOZOMATとは

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

zozomat

zozomat-renderer-view

ZOZOMATの構成

ZOZOMATの機能は社内ライブラリとして開発されており、ZOZOTOWNに組み込まれています。以下は依存関係の一部です。ZOZOMATの機能を提供しているライブラリはZOZOMATフレームワークと呼ばれており、フレームワークはさらに計測結果の3Dモデルの表示するためのZOZOMAT Rendererに依存しています。

本記事ではタイトルにもあるとおり、そのZOZOMAT RendererのOpenGL ESからMetalへの移行についてお話しします。

ZOZOMATの依存関係

移行の背景

足の3Dモデルのレンダリング使っているOpenGL ESはiOS12でDeprecatedになっており、将来的に利用できなくなる可能性がありAppleもMetalへの移行を推奨しています。

developer.apple.com

検討したアプローチ

ZOZOMAT Rendererは以下の図にあるようにクロスプラットフォームに対応しています。そのため、単純にプラットフォーム非依存レイヤーの中のOpenGL ESをMetalに書き換えることはできません。

現状のRendererの構成

techblog.zozo.com

移行するためには引き続きAndroidをサポートしつつiOSでのみMetalで動作するようにしなければいけません。そのためのアプローチは大きく分けて2つありました。

  1. bgfxのようなMetalをバックエンドとして利用可能なクロスプラットフォームのレンダリングライブラリに移行する
  2. バッファ作成や描画処理といったグラフィックス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ではバックエンドレイヤーでMTLBufferMTLRenderPipelineStateの生成をするために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. 作成時(参照カウント+1): CFBridgingRetainでARC管理からC構造体の手動管理に移行

    • newBufferWithBytes:などでMetalオブジェクトを作成(参照カウント=1)
    • CFBridgingRetainで参照カウントを+1し、C側で保持(参照カウント=2)
    • ARC管理下のローカル変数がスコープを抜けると-1(参照カウント=1、C側のみが保持)
  2. 使用時(参照カウント変化なし): __bridgeで一時的にObjective-CオブジェクトとしてObjective-C++で参照

    • 参照カウントは変化せず、単にキャストのみ実行
  3. 破棄時(参照カウント-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では、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。

corp.zozo.com

カテゴリー