Androidでリッチな3DCGを扱う〜Google Filament事始め〜
この記事は、NAVITIME JAPAN Advent Calendar 2020、2日目の記事です。
みなさんこんにちは、三代目ゆうです。ナビタイムジャパンでAndroid/iOSアプリ向け地図フレームワークやAR描画フレームワークの開発を担当しています。
この記事では、Google製のレンダリングエンジンFilamentの公式サンプルアプリを読み解きつつ、ゼロから導入していく手順について解説していきます。
はじめに
ARやVR、総括してXRと呼ばれることも増えましたが、近年そういった技術が脚光を浴びています。と言いますか、浴び続けてVR元年が何年続くのかといったツッコミも聞こえてきそうな様相です。ただ、これは自分の主観なのですが、エンタメ分野では普及して久しいこれらの技術も、実用系のツールやサービスとしてはまだまだ模索段階なのではないかなと感じています。
というわけで、当社でも「AR技術をサービスで活用しよう」と模索する動きが活発になってきました。いきなりの宣伝で恐縮ですが、私の開発しているAR描画フレームワークを用いたアプリ「スゴ得版NAVITIMEレンズ」が先日リリースされました!ドコモのスゴ得サービス加入者の方限定にはなってしまいますが、ご興味ありましたらぜひお試しください!
と、ここまでARの話ばかりしてきましたが、本記事で触れる領域はARではありません。ARにしろVRにしろ、避けては通れない技術領域3DCGの話になります。
3Dを扱ったアプリと言うと、今となってはUnityを使う事が一般的かと思いますが、中にはネイティブアプリで実現しなくてはいけない込み入った事情がある人もいるでしょう。そうです、私です。
※真面目な話をしますと、Unityを使った場合UIがネイティブアプリとかけ離れてしまう、アプリサイズが肥大してしまうといったデメリットもあり、アプリの方向性次第で導入が難しい場合も多いのではないでしょうか。
さて、ネイティブアプリで3DCGを扱う場合、最初の選択肢はプラットフォームのプリミティブな描画APIを利用することでしょう。Androidであれば、OpenGL ESやVulkanがそれにあたります。しかし、描画APIを直接使うのは高度な専門知識が求められますし、プラットフォームごとに固有の実装が求められる事にもなるでしょう。
そこでオススメしたいのが、FilamentというGoogle製のレンダリングエンジンです。
詳しい話は後述するとして、Filamentのメリットはプラットフォーム固有の描画APIをラップし使い勝手の良い共通APIとして提供してくれることです。一方デメリットとして、なんと言っても世の中にリファレンスが少ないことが挙げられます(個人調べによる)。さらに言えば日本語でまとまった情報は殆どありません。そして、元々3DCGプログラミングに慣れ親しんでいる方ならまだしも、私のようないわゆるアプリエンジニアとしてはなかなか取っつきにくいライブラリなのも事実です。
そこで、Filamentの導入を検討している、または知らなかったがネイティブアプリで3DCGを扱いたい事情がある方の一助となることを願って、本記事を執筆したいと思います。
この記事の想定読者
・Androidアプリ開発の基礎知識がある
・3DCGについて少しでもかじった事がある
※下記のような頻出単語についてなんとなく意味が分かる程度
(e.g. ポリゴン、頂点バッファ、インデックスバッファ、シェーダー、変換行列)
バージョンについて
この記事はFilamentバージョン1.8.1時点でのサンプル実装を元に記載しています。
なお、記事執筆時点でバージョン1.9.9までリリースされていますが、記事内で触れる範囲についての変更はありませんでした。
Filamentとは?
https://github.com/google/filament
FilamentとはGoogleが開発している物理ベース(物理法則に従った光の反射、透過を基本とする)のリアルタイムレンダリングエンジンです。
Filamentはマルチプラットフォームに対応しており、各プラットフォームのプリミティブな描画APIをラップし、独自の共通APIとして提供してくれます。その中でも特にAndroid上で可能な限り小容量かつ高効率になるよう設計されているとのことです(さすがGoogle製)。
対応プラットフォーム:Android, iOS, Linux, macOS, Windows, WebGL
対応描画API:OpenGL4.1+, OpenGLES 3.0+, Metal, Vulkan, WebGL
Filamentのメリットとしては、描画APIを直接使うのに比べて非常に簡易かつ分かりやすく、それでいて物理ベースという前提であれば柔軟性も同じくらい高いことだと思います。また、プラットフォームごとに差分が生まれがちな描画ロジック周りの設計、実装を統一出来るという利点も大きいと思われます。
サンプルを動かしてみよう
何はともあれ、まずはサンプルアプリを動かしてみましょう。(と言いたいところなのですが、ツール郡の初回ビルドにとても時間がかかるため実際に動かすのはお時間に余裕のある時がベターです。)
1. 公式リポジトリをクローン、またはzipでダウンロードしてくる
2. ドキュメントのPrerequisitesを参考にビルド環境を整える
3. 1でクローンしてきたFilamentリポジトリのルートディレクトリで下記コマンドを実行
※数時間かかる事もあるので、余裕のある時に始めましょう。
$ ./build.sh -p desktop -i release
$ ./build.sh -p android release
4. Android Studioで filament/android ディレクトリを開き、Run Configurationから sample-lit-cube を選択し実行
※今回は解説の関係からsample-lit-cubeを指定しておりますが、他にも複数のサンプルアプリが同梱されています。サンプルアプリの一覧はREADMEにスクリーンショット付きで記載されていますので参考にしてください。
アプリが実行され、以下のような画面になったらOKです。
サンプルを読み解いてみよう
それでは、無事にサンプルアプリを動かせたところで、いよいよFilamentの導入方法について解説を進めていこうと思います。
ここからは、先ほど実行した sample-lit-cube アプリのソースコードを、アプリを実装する際の手順を意識しつつ読み解いていきましょう!
Filamentライブラリの導入
まずはアプリへのライブラリの導入方法についてです。
が、いきなり前提を覆すようで恐縮ですが、先述のサンプルアプリはあまり参考になりません(ライブラリ本体もプロジェクトのモジュールとして含まれているため)。
とは言え、ライブラリの導入は慣れ親しんだ方法で依存を追加するだけです。app層モジュールのbuild.gradleに以下の記載を追加します。
dependencies {
implementation "com.google.android.filament:filament-android:1.8.1"
}
Filamentの初期化
では、いよいよサンプルの実装を見ていきましょう!
これ以降、別途の記載がなければコードブロックはすべてサンプルコードのMainActivity.ktからの引用となります。ただし、各セクションごとに注目するコード以外の省略や、コメントの追加などを行っているため、全文や前後の文脈については本来のソースコードと併せて確認いただけると助かります。
まず初めに、Filamentを初期化します。この処理は、FilamentのAPIを使う前に必ず呼び出す必要があります。下記サンプル例では、Activityの静的初期化時に実行していますが、ApplicationクラスのonCreateなど1回のみ実行される事が保証されている場所が適切かもしれません。
companion object {
init {
Filament.init()
}
}
Engineの生成
Filamentの初期化が出来たら、Engineを生成します。今後、Filamentリソースの生成や破棄はこのEngineを通して実行することになります。
private lateinit var engine: Engine
private fun setupFilament() {
engine = Engine.create()
}
SurfaceViewの生成およびUiHelperの活用
AndroidではFilamentのレンダリング結果を指定したSurfaceに対して描画します。SurfaceをViewとして画面に表示するにはSurfaceViewまたはTextureViewを用いる方法がありますが、その実装を簡単にするためにAndroid版Filamentの独自APIとしてUiHelperというクラスが用意されています。
生成したUiHelperに対して、SurfaceViewまたはTextureViewをアタッチし、適切なコールバック(UiHelper.RendererCallback)を実装します。
コールバックの実装方針は以下の通りとなります。
・onNativeWindowChanged(surface: Surface)
アタッチしたViewのSurfaceが生成された場合に呼び出されます。
引数に渡されたsurfaceからSwapChainを生成します。(SwapChainはGPUの描画結果を実際の画面に反映するためのインターフェースで、FilamentにおけるSurfaceの言い換えとも言える)
・onDetachedFromSurface()
明示的にdetach()を呼び出した場合、またはViewによってSurfaceが破棄された場合に呼び出されます。生成済みのSwapChainがある場合は、これを破棄します。
・onResized(width: Int, height: Int)
アタッチしたViewの持つSurfaceの大きさが変更された場合に呼び出されます。サンプル例ではFilamentのView(≠AndroidのView、詳しくは後述します)のViewportの大きさを設定しています。
private lateinit var surfaceView: SurfaceView
private lateinit var uiHelper: UiHelper
override fun onCreate(savedInstanceState: Bundle?) {
surfaceView = SurfaceView(this)
setContentView(surfaceView)
setupSurfaceView()
}
private fun setupSurfaceView() {
uiHelper = UiHelper(UiHelper.ContextErrorPolicy.DONT_CHECK)
uiHelper.renderCallback = SurfaceCallback()
uiHelper.attachTo(surfaceView)
}
inner class SurfaceCallback : UiHelper.RendererCallback {
override fun onNativeWindowChanged(surface: Surface) {
swapChain?.let { engine.destroySwapChain(it) }
swapChain = engine.createSwapChain(surface)
}
override fun onDetachedFromSurface() {
swapChain?.let {
engine.destroySwapChain(it)
engine.flushAndWait()
swapChain = null
}
}
override fun onResized(width: Int, height: Int) {
view.viewport = Viewport(0, 0, width, height)
}
}
各種リソースの生成
Filamentでの描画に必要な各種リソースを構築します。Filamentで描画を行うには主に以下のリソースが登場します。
・Renderer
描画処理を担当するクラス。インスタンスは単一のSurfaceに紐付けられる。Viewを元に描画コマンドを生成し、実行する。
・View
レンダリング用のScene、Camera、Viewportを定義するクラス。「AndroidのView」とは異なるクラスなので注意。
・Scene
描画する世界の空間。描画対象となるすべてのEntityを持つ。
・Camera
Sceneを描画する際の視点、視野となるカメラ。
・Entity
Scene内に存在する”物”。描画物であるRenderable、光源であるLightなど複数の種類がある。
このうち、サンプルアプリにおいては(恐らく多くのアプリにおいても)Entity以外は単一で済むため、初期化時にすべて生成しています。
private lateinit var renderer: Renderer
private lateinit var scene: Scene
private lateinit var view: View
private lateinit var camera: Camera
override fun onCreate(savedInstanceState: Bundle?) {
setupFilament()
setupView()
}
private fun setupFilament() {
renderer = engine.createRenderer()
scene = engine.createScene()
view = engine.createView()
camera = engine.createCamera()
}
private fun setupView() {
// 背景となるSkyboxを設定
scene.skybox = Skybox.Builder().color(0.035f, 0.035f, 0.035f, 1.0f).build(engine)
// ViewにCamera、Sceneを設定
view.camera = camera
view.scene = scene
}
マテリアルの用意とコンパイル
前項までで、描画を行うための下地が整った状態となりました。ここで、一度アプリ側の実装から離れてマテリアルというファイルを用意する必要があります。
Filamentにおけるマテリアルとは、描画物の質感を表現するための仕組みであり、独自フォーマットのソースファイルによって定義されます。マテリアルのソースファイルは、全体としてはJsonライクな構造となっており、大まかにmaterialブロック、vertexブロック、fragmentブロックで構成されます。
materialブロックにはマテリアルのプロパティが定義されます。基本的には、このブロック内のプロパティ定義を変える事で一般的な材質を再現することが出来ます。
vertexブロック、fragmentブロックはそれぞれvertexシェーダー、fragmentシェーダーを記述する事ができます。シェーダーはGLSL(GL Shader Language)ライクに記述が可能で、複雑なシェーディングを実現したい場合に実装を行います。vertexブロックは独自の頂点制御が不要な場合は省略可能で、fragmentブロックでは必ず prepareMaterial(material); の呼び出しを記載する必要があります。また、materialブロックで独自パラメータを定義した場合は、引数に渡されるmaterial構造体に対し値をセットすることでGPU側に反映させる事が出来ます。
引用元:lit.mat(一部中略、コメント追加)
material {
name : lit,
// ライティングの有効なモデルであることを定義
shadingModel : lit,
// このマテリアルが持つ独自パラメータ、この値は実行時にアプリのコードから設定できる
parameters : [
{
type : float3,
name : baseColor
},
{
type : float,
name : roughness
},
{
type : float,
name : metallic
}
],
}
fragment {
void material(inout MaterialInputs material) {
prepareMaterial(material);
// シンプルな実装では、設定されたパラメータをGPU側に渡すだけでOK
material.baseColor.rgb = materialParams.baseColor;
material.roughness = materialParams.roughness;
material.metallic = materialParams.metallic;
}
}
また、マテリアルのソースファイルはそのままでは実行時に読み込むことが出来ないため、事前にコンパイルしておく必要があります。マテリアルのコンパイルに使うのが matc というツールで、サンプルを実行する際にbuild.shを回している場合は filamet/out/release/filament/bin/ 配下に出力されていると思います。また、自前でビルドしなくてもリリースページからご自身のプラットフォームにあったパッケージ(MacOSで開発する場合は filament-v1.8.1-mac.tgz )をダウンロードすればビルド済みツールを入手することが出来ます。
matcを使い以下のようにソース(.mat)をバイナリ(.filamat)にコンパイルします。成果物であるバイナリファイルをアプリのアセット(/assets)やバイナリリソース(/res/raw)として取り込み、Androidアプリ内から読み込むことで利用できます。
$ matc -o lit.filamat lit.mat
サンプルのプロジェクトでは、gradleスクリプトを用いてアプリのビルド時に同時にマテリアルのコンパイルも実行するようにしています(そのため、サンプルを実行するのに長時間のビルドに耐える必要があったのです)。なお、その方法については本記事では趣旨から外れてしまうため解説は割愛します。
Entityの生成およびSceneへの追加
描画の下地と必要な素材が揃ったところで、改めてアプリのソースコードに戻ります。
さて、実際に何か”物”を描画するためには、その”物”自体を配置してあげなくてはいけません。そこで、”物”を表すEntityを生成し、Sceneへ追加します。Entityは主に2種類があり、描画物を表すRenderableと、光源を表すLightがあります。それぞれ、Entityに対して必要なパラメータを含めて設定してあげることでそのEntityの属性を定める事ができます。
Renderableを作る場合は大きく分けて2つの素材が必要になります。まず一つ目が前項で作成したMaterialです。マテリアルファイルを読み込み、MaterialInstanceを生成し、必要なパラメータを設定します。もう一つがメッシュという、いわゆる3Dモデルの形状データです。サンプルではcreateMesh()メソッド内で動的に立方体のモデルを生成しています。メッシュはVertexBufferとIndexBufferによって構築されます。VertexBufferには、1頂点あたりに頂点座標だけでなく、法線、UV、カラーといった情報を乗せる事ができます。
Lightを作る場合はもう少しシンプルで、生成したEntityに対してその光源の設定をしてあげるだけで済みます。サンプルの例では直接光源を太陽光を模した色味、強さで設定しています。
それぞれ生成したEntityをSceneに対してaddEntityすることで追加します。
private fun setupScene() {
loadMaterial()
setupMaterial()
createMesh()
// renderable用のEntityを生成
renderable = EntityManager.get().create()
// renderableに対してメッシュとマテリアルを設定
RenderableManager.Builder(1)
.boundingBox(Box(0.0f, 0.0f, 0.0f, 1.0f, 1.0f, 1.0f))
.geometry(0, PrimitiveType.TRIANGLES, vertexBuffer, indexBuffer, 0, 6 * 6)
.material(0, materialInstance)
.build(engine, renderable)
// 生成したrenderableをSceneに追加
scene.addEntity(renderable)
// light用のEntityを生成
light = EntityManager.get().create()
// lightに対して光源タイプ、色味、強さ、照射方向などを設定
val (r, g, b) = Colors.cct(5_500.0f)
LightManager.Builder(LightManager.Type.DIRECTIONAL)
.color(r, g, b)
.intensity(110_000.0f)
.direction(0.0f, -0.5f, -1.0f)
.castShadows(true)
.build(engine, light)
// 生成したlightをSceneに追加
scene.addEntity(light)
}
private fun loadMaterial() {
// assets配下から指定ファイルを読み込み
readUncompressedAsset("materials/lit.filamat").let {
material = Material.Builder().payload(it, it.remaining()).build(engine)
}
}
private fun setupMaterial() {
// loadMaterial()で読み込んだmaterialから個別のインスタンスを生成、パラメータを設定する
materialInstance = material.createInstance()
materialInstance.setParameter("baseColor", Colors.RgbType.SRGB, 1.0f, 0.85f, 0.57f)
materialInstance.setParameter("metallic", 0.0f)
materialInstance.setParameter("roughness", 0.3f)
}
private fun createMesh() {
// 長いためコードは全略
// vertexBufferとindexBufferを生成する処理
}
Cameraの制御
EntityをSceneに追加したことで、描画するための空間の準備は出来ました。後は、その空間をどこから写すか、すなわちCameraの設定です。
Cameraは基本的に、Camera自身の姿勢(=Cameraの場所、回転)と透視投影の設定が必要になります。姿勢および透視投影は、パラメータ指定のメソッドで設定する方法と、変換行列を直接設定する方法がありますが、サンプルでは前者の方法が取られています。
また、FilamentのCameraは現実のカメラと同じように露出を設定することでScene全体の明るさを制御することが出来ます。露出は絞り値、シャッタースピード、ISO値によって設定できます。
private fun setupScene() {
// 露出の設定(絞り値f/16、シャッタースピード1/125sec、100 ISO)
camera.setExposure(16.0f, 1.0f / 125.0f, 100.0f)
// カメラの姿勢(前から3引数ごとに、カメラ位置、視点位置、上部ベクトル)
camera.lookAt(0.0, 3.0, 4.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0)
}
// UiHelper.RendererCallbackの実装メソッド
override fun onResized(width: Int, height: Int) {
val aspect = width.toDouble() / height.toDouble()
// 透視投影設定(FOV角度、アスペクト比、クリッピングプレーン距離(near, far)、FOVの方向)
camera.setProjection(45.0, aspect, 0.1, 20.0, Camera.Fov.VERTICAL)
}
ちなみに、露出設定を変えると以下のようになります。本物のカメラと違い、シャッタースピードを遅くしても残像が映るような事はありません。
Choreographerによるフレーム制御およびRendererによる描画
長い道のりでしたが、いよいよ描画処理を呼び出します!
リアルタイムレンダリングは定期的にそして高速に繰り返して実行する必要があります。最も愚直な方法としてwhileループによる実装が考えられますが、ディスプレイのリフレッシュレートとレンダリング処理のタイミングが異なってしまうため、適切な描画が出来ない可能性があります。
そこで、AndroidのAPIであるChoreographerを使うことによりディスプレイのリフレッシュレートと同期したタイミングのコールバックを受け取る事ができます。ChoreographerのコールバックごとにRendererのレンダリング処理を実行することで、リフレッシュレートに同期した描画を実現出来ます。
private lateinit var choreographer: Choreographer
private val frameScheduler = FrameCallback()
override fun onCreate(savedInstanceState: Bundle?) {
choreographer = Choreographer.getInstance()
}
override fun onResume() {
// 最初のフレームコールバック通知を登録
choreographer.postFrameCallback(frameScheduler)
}
override fun onPause() {
// コールバック通知を削除
choreographer.removeFrameCallback(frameScheduler)
}
inner class FrameCallback : Choreographer.FrameCallback {
override fun doFrame(frameTimeNanos: Long) {
// 次フレームのコールバック通知を登録
choreographer.postFrameCallback(this)
if (uiHelper.isReadyToRender) {
// レンダリング処理
if (renderer.beginFrame(swapChain!!, frameTimeNanos)) {
renderer.render(view)
renderer.endFrame()
}
}
}
}
おわりに
ここまで読んでくださった皆様、ありがとうございます!そしてお疲れさまでした!(本当に!)
チュートリアル記事のつもりが、随分なボリュームになってしまいました。しかし、そのぶんAndroidネイティブアプリでFilamentを扱う上で必要な基礎知識はだいぶ網羅出来たのかなと思います。
この記事が、これからAndroidで3DCGを扱う方の足がかりとなってもらえれば幸いです。