👾

巨大モンスターをビルにARで出現させる

2023/02/10に公開

MoAR の 2022 年アドベントカレンダー 23 日目の記事です。(今は2022年の12月73日...年末に書く宣言したアドベントカレンダーめちゃ遅れて今できました。) どうも私は Whatever のプログラマーの貴田です。

2022年夏に乃木坂にある Whatever 社からリリースされた MoAR アプリは、会社のビルに iPhone をかざすと、ビルに連動してたくさんのARシーンを見ることができます。私が担当したのは、その中の1つである、巨大なモンスター「emoc」(エモック)が登場する AR シーンのプログラム開発です。

この記事では、巨大なモンスター「emoc」(エモック)のAR開発にまつわる話を書きます。

emoc(エモック)とは?

デザイナーの太一さんが考案したキャラクターです。

このビルには、いつも誰かを待ってメソメソ泣いている、儚くて巨大でヘンテコなemocという名のいきものがいます。ビルの中から飛び出して、屋上まで駆け上り、遠くを見ながら待ち人を待ち焦がれています。さみしがりやさんだけど、好奇心旺盛でいたずらが大好きです。プリプリのおしりを振って一生懸命踊ったり、通行人にたべものを投げたり、色んな動きで通行人を楽しませてくれるでしょう。実はその正体は、このビルを待ち合わせに使った人たちの思いが具現化された生命体らしいのです。

引用:MoAR の emoc 紹介ページより

このめちゃくちゃかわいいキャラクターをAR でビルに出現させることになりました。できるだけこのこのかわいさを産地直送で、そのままARで見れるようにしたい。

emoc(エモック)はどんなアクションをする?

以下の図は、ビルのどの位置で、emocがどんなアクションをするか?をまとめた図です。

ビルの上段から虹ゲロを吐いてきたり。(ACT03: 虹ゲロを吐く)
ビルの中段から壁を突き破って出てきたり。(ACT02: 壁から出てくる)
ビルの下段でダンスを踊ったりします。(ACT01: ダンスを踊る)

1つ1つのアクションについても詳細な絵コンテがあります。
以下は「ACT03:虹色のゲロを吐く」の絵コンテです。

アクションの設計・演出コンテはデザイナーの太一さん作です。
これをもとに、AR をつくっていきます。

これ以降は、実装にまつわる話を書いていきます。

iOS・SceneKitで扱いやすい 3D モデルのファイルフォーマットの選定

アドベントカレンダー5日目の AR 表現に使用する 3D フレームワーク選定 の記事で CTO の Saqoosha さんが書かれてる通り、MoAR の3Dレンダリング環境は iOS の SceneKit です。Unityは使えません。

今回のプロジェクトで SceneKit を触るのが初めてだったので、一番表現力が高そうな3Dファイルフォーマットをリサーチするところから始めました。結論、私がリサーチした範囲では「USDZ」が一番よさそうでした。(2022年夏時点の話です)

比較したのは glb、SCN、COLLADA、USDZ の 4 つのファイルフォーマットです。
重視した観点は以下です。

  • 基本的なメッシュ、マテリアルが表示できる
  • アルファが表現できる
  • スキニング、リギングが正常に表示できる
  • サーバーからのダウンロードが可能

比較方法は KhronosGroup/glTF-Sample-Models に公開されてる GLB 形式のさまざまな機能を持つ 3D モデル達を各種フォーマットに変換して、確認しました。

当時の比較表 を貼ります。


(ZennにMarkdownでTable 貼ったら、めちゃはみ出たので、スクショ画像で)

1点この表に関して、検証後に知った補足事項があり。glb については、私が試した magicien/GLTFSceneKit  以外にも、  warrenm/GLTFKit2 というライブラリがあるらしく。 Saqoosha先生が Cinema4D まわりで試行錯誤されている際に、横目でそこそこ動いてる印象を受けたので、やや気になっていますが、未検証です。

以下は  KhronosGroup/glTF-Sample-Models  の3Dモデルを全部ダウンロードして、 USDZ に変換して、破綻がないか?を検証した動画です。glTFのモデルの中でExtensionsFeature Tests に該当する機能を有するモデルはうまく表示できてないものが多いですが、それ以外のスタンダードな3Dモデルはおおよそ正常に動いてるように見えました。

https://www.youtube.com/watch?v=flIdUG6RgKE

というわけで、SceneKitで使う3Dモデルのファイルフォーマットとしては、USDZがかなり良いと思います。が、実装中に以下2点が不便だと思いまして。アドベントカレンダーの他の記事にその解決策があるので、紹介させてください。

不便1: USDZ圧縮ができない問題 → gzip で解決

USDZはメッシュやアニメーションを圧縮する方法が見つかりませんでした。glb だと draco、meshopt などのアルゴリズムで圧縮できるんですが。USDZ は圧縮する方法が見つからなく、容量が重いままでした。この問題は「GitHub Action で AWS S3 にアップロード」にあるように gzip する作戦でかなり容量が圧縮されて解決しました。

不便2: アニメーションのプレビューが面倒 → usdview が便利そう

Xcodeやpreview.appでUSDZファイルを開くとアニメーションのタイムラインのシーケンスから秒数指定など細かい操作ができず、面倒だなーと思ってました。この問題は、ゆーじさんが Maya と USDZ で SceneKit(1) で紹介してくれてる PIXAR の usdview を使ってプレビューすると、アニメーションのタイムラインがバーで操作できるだけでなく、USDZ を確認するためのいろいろな機能が圧倒的に便利そうでした。

※ 制作中に Wizart DDC Platform というのがリリースされて、これも気になっているのですが、結局さわれていません。気になる。

emoc の 3D モデル制作

太一さんのイラスト・演出コンテをもとに、さとうゆーじさん ( @satoy111 )を筆頭にKASSENさんに ご作成いただきました。制作環境は、Mayad で、そこから USDZ 形式に Export して頂きました。

一番初期のemocモデル。なつかしい。

Maya で iOS SceneKit に対応した 3D モデルをご制作して頂くにあたって、世間的に未開拓分野すぎて、ハマった罠はたくさんありまして。その知見をゆーじさんが詳しく書いてくださってます。

内容的に同じ話もありますが、3Dモデル制作を実装側からお願いさせていただく立場で、次回、覚えておきたい、と思ったことを列挙します。

ボーンアニメーション

ボーンアニメーションはできる。しかし、いくつか制約があるので、気を付けることリストです。

1スケルトン- 1USDZにする

シーン内に複数 Skeleton を持つキャラクターがいて、そのシーン全体を maya から 1 つの USDZ ファイルに Export すると、1つのキャラクターにしかボーンアニメーションが反映されない。複数スケルトンは複数 USDZ に分けて出力する必要がある。

1タイムラインで複数アクションを管理する

調査した結果、 以下 2 つは Maya+USDZ環境では実現が難しいことがわかりました。

  • 複数アクションを別タイムラインとして USDZ 内に持たせられない(※USDZ単体では可能)
  • モーションを個別のファイルとして持たせることもできない

なので、1つのタイムラインに、複数アクションを入れて、「Action2は開始60Frame目-終了90frame目」などと仕様を決めて、swift側でフレームを切り替える必要がある。実際にこんなコード書いて、開始フレームと終了フレームを管理してました。

static let MOTION_TIMES: [MotionType: MotionTime] = [
        MotionType.Idle: MotionTime(start: 0.267, end: 1.333),
        MotionType.Act01: MotionTime(start: 1.533, end: 18.333),
        MotionType.Act02: MotionTime(start: 18.533, end: 36.800),
        MotionType.Act03: MotionTime(start: 36.867, end: 54.133),
        MotionType.Act04: MotionTime(start: 54.200, end: 80.067),
        MotionType.Opn01: MotionTime(start: 80.267, end: 96.067 ),
    ]

※あと、この方法だと、モーションのblendもできないと思われます。

待機ループモーション作成時は、Startフレームと同じものを約 3 フレーム挟むべし

待機ループのボーンアニメーションを作る時もコツがありました。例えば、1Frame目:バインドポーズ、2Frame〜30Frame目:待機ループとして、2Frame〜30Frameをぐるぐる回すと、以下のようにアニメーションがカクつきました。

YouTubeのvideoIDが不正ですhttps://www.youtube.com/shorts/GePlwVZZQPE

原因は iOS の SceneKit 上で、2Fのポーズが、 1F目のバインドポーズに影響を受けてしまう事だと推測しています。この症状は USDZ を Xcode 上や、usdview 上で再生したときは確認できませんでしたが、iOS の SceneKit上でのみ起こりました。対策としては、1F目の影響を抑えるため、2frameをコピペし、以下のような構成にして、

  • 1frame目:バインドポーズ
  • 2,3frame目 : 待機モーションの最初のフレーム(使わない)
  • 4〜32frame目: 待機ループモーション

4〜32frame目をぐるぐる回すとカクツキの心配はなさそうです。

BlendShape

スライムのアニメーションで使っている。BlendShape単体なら動きました。

YouTubeのvideoIDが不正ですhttps://youtube.com/shorts/FjCdbBoqX8s

ボーンアニメーション+ BlendShape を 1つのキャラクター内で1つのタイムラインで動かすと、アニメーションがズレていく。例えば、emoc の口の動きはもともとBlendShapeで処理していただいており、舌と口の動きがズレてしまう現象が起こってました。

ゆーじさんが BlendShape をボーンアニメーション化する SSDR を隠し技的に発動していただき、emocの演出上はなにもあきらめることなく、実現できたのですが。ゆーじさんがBlendShape の欠点で書かれてる通り、ファイルの容量が増えたり、変換に工数もかかるので、 Swift SceneKitの環境では、BlendShapeはここぞ、という時に使うことにして。多用しすぎない方が無難という学び。

その他、3Dモデル制作をスムーズに進行する上で持ちたい視点リスト

  • 座標系の違い。 「swift 側で回転しとけばとりあえず問題ないでしょ」と思わずに、あとから修正すると大変になるので、できるだけ初期フェーズで座標系問題は細かいとこまで解決すべし。
  • 「テクスチャは制作終盤でも変更しやすい」「メッシュ構造、モデルのスケールはアニメーション制作が始まったら、変更するの大変」など。CG制作には、各マイルストーンごとに、「今決めないと、あとから引き返しにくくなること」などがあるらしい。私はCG制作の経験が少なく、ゆーじさんに毎回聞いてるきがする。覚えたい。
  • モデルの制作が進むごとに、メッシュ構造(メッシュ名や、どのメッシュにどのアニメーションがついてるか、等)は変わるもので。しかし、それにあわせて swift 側のコードも書き換える必要があるため。できる限り interface 的に事前にメッシュ構造を固定して進められると、モデリング→実装組み込みのワークフローがスムーズになりそうとおもい。いい方法がないか気になりました。メッシュ構造は、Maya の USDZ Exporterが勝手に変えてそうなので、どこまで固定化できるかは次回があれば調査したいです。
  • アニメーションについては、精度の高いアニメを作っていただく前に、「ブロッキング」(←この単語を知らずにご迷惑をおかけした)程度のラフなアニメで、一度、 現場で実機AR でみてみて、尺感・サイズ感・位置などつかむ、のがかなり有効でした。
  • Blend Shape は CGソフトによって、ShapeKey、 MorphTarget とかいろんな呼ばれ方をしてる模様。チームの中で呼び方を揃えるの大事。(自分はわかっても、みなさんを混乱させてしまう
  • 今回の様なダウンロードサイズを最小に抑えたいプロジェクトの場合、容量を気にしながら作る。容量を下げたい場合、テクスチャを圧縮する・解像度を下げる、メッシュの解像度を下げる、アニメーションのFPSを下げる、などの手がありそうです。中でも、USDZは、アニメーションが GLB と比べると重くなりやすい印象でした。キーフレームの間隔を 30→15fps 程度に下げても SceneKit 側で補間が効くのでカクついたりはしない印象でした。それでもまだ容量が重い場合は、↑に書いた通り gzip 圧縮が効果的でした。

ARの確認はなるべくARで行うべし

emoc のサイズ感について以下のような要件がありました。

  • emoc はビルの下段・中部・屋上のさまざまな位置に出現させたい
  • 屋上では遠目からみても迫力ある大きめのサイズで出現させたい
  • 下段にいるときは歩道にいるユーザのスマホ画角に収まるサイズになってほしい

「屋上での大サイズ」・「地上での小サイズ」を決めるため、まずemoc の仮モデルを AR でビルに配置しスケールをいじって検証しました。

YouTubeのvideoIDが不正ですhttps://www.youtube.com/shorts/XXP2QGxl2XM

このような工程を経て、「屋上でのemoc大サイズ」・「地上でのemoc小サイズ」の 2 サイズが決まりました。

emoc のモデルは大・小の2つのスケール感

※ 注:emocの体のスケールを実装側で動的に可変にすると、アニメーションが破綻してしまう懸念があり。今回は、動的にスケール変更するのではなく、大・小の2つのモデルを出し分ける、という対応をしました。ただ、このビルに住んでる emoc は 1  体なので、もし次回があれば、 1 つのモデルでスケールができないか検討したいです。課題。

また、スケール感を検証をしたら、3Dモデルで見てたのとは違う視点で、以下のような意見がチーム内であがりました。

from 太一さん「モンスターが直立してると、カメラからに emoc の顔が見えにくい」

直立してると顔が全然みえないemocの図

from りょうさん「ドット柄のパンツが目に入りやすいので、テクスチャの粗さが目立つ」

このように、ARで見ると、3Dモデルビューワーでの確認とは違う視点の気づきが 得られることがあるので、ARの演出確認は、ちょくちょくARビューで行いました。

この「CGチームにモデリング・アニメーションを USDZ 化して頂く」 → 「実際にAR でみれるようにプログラマーが組み込んでみる」→「クリエイティブメンバーから色々フィードバック」のイテレーションは、各アクションの演出チェックでも、何度も何度も発生しました。このループをより効率的なワークフローにしていけるといいなぁ。

今回は、「体験者が最も多くARを見るであろうホットスポットのカメラ位置、iPhoneのカメラのレンズパラメータを、開発チームとCGチームで共有し、CGプレビズ時になるべくスマホ AR に近い画角にしていただく」という試みを行いました。ただ、画角だけの問題ではなく、MayaとSceneKitでレンダリングの仕組みが異なるため、「Mayaの動画書き出し」と「 iOS SceneKit 」上で見え方が異なるという問題もよく起こっていました。例えば、建物のうしろに emoc が隠れたはずなのに、ちょびっと見切れるなどの問題が頻発しました。それを解決するには、 USDZファイルをすぐにリプレイデータを使って、SceneKit 上で再生・録画できるような仕組みを作れたら、もっと制作&フィードバックの効率が上がりそう、と思いました。(参考 : Saqooshaさんの「リプレイデータを使ってARコンテンツ開発する」)考えたい。

プログラム(swift)側でやったこと

AR上に USDZ モデルを配置し、アニメーションを再生するのが、おもな swift プログラムの役割でした。しかし、USDZ モデルだけでは以下が苦手なので、swift 側でやる必要がありました。

  • メッシュの visible の ON/OFF
    • 例:  「目パチ」や「飛んだ目玉のフェードアウト」など
  • マテリアル の変更・アニメーション
    • 例:  「虹ゲロの虹部分」など
  • パーティクル系の演出
    • 例:  「虹ゲロの飛び散り」など
  • 物理演算がいる演出
    • 例:  「割れた建物の破片アニメ」
  • レンダリング設定 : ライティング、影まわり
  • アニメーション再生秒数に応じた各種イベントの発火

USDZに組み込まれたモーションとタイミング連携が必要な演出については、実装時にこんなリストを作り、役割分担しました。


(swiftとUSDZが未経験で、どの表現まで可能?の感覚がつかめず、この簡単な役割分担表に辿りつくまでに、時間がかかった記憶...)

例えば「 虹ゲロを吐く」の絵づくりはどういう分担?

emocのアクションの 1 つに「虹ゲロを吐く」というのがあります。以下、動画です。


https://www.youtube.com/shorts/MGeG19zNFG8

太一さんにどのへんに虹ゲロを落としたいか?ていう指定をもらい。

KASSENさんに虹ゲロと虹ゲロ溜まり(下部分)のモデルを作っていただき。

emoc が吐くポーズになったタイミングで、以下を swift でやっています。

  • metal shader で虹ゲロの塗りを 透過 → テクスチャ色に
  • SCNParticleSystem でキラキラパーティクルをスケールアニメ
  • 虹ゲロ溜まりの広がりはモデルにつけていただいた BlendShape を SCNMopher でアニメーションさせる


影がちゃんと擬似半透明でビルに落ちてるのがポイント

虹ゲロのmetal。

//  
// Vertex  
//  
  
vertex VertexOut kishimenMatVert(  
    VertexInput in [[ stage_in ]],  
    constant NodeBuffer& scn_node [[buffer(1)]]  
)  
{  
    VertexOut out;  
    out.position = scn_node.modelViewProjectionTransform * float4(in.position, 1.0);  
    out.uv = in.uv;  
    out.vNormal = normalize(in.normal);  
     
    return out;  
}  
  
  
  
//  
// Fragment  
//  
  
  
fragment float4 kishimenMatFrag(  
    VertexOut in [[ stage_in ]],  
    texture2d<float, access::sample> matTexture [[texture(0)]],  
    constant NodeBuffer& node [[buffer(1)]],  
    constant float &mrate [[buffer(2)]] // motion rate : 0.0 〜 1.0  
)  
{  
  
    //  
    // Y方向に scroll animation  
    //  
    constexpr sampler textureSampler(coord::normalized,  
                                    filter::linear,  
                                    address::repeat);  
     
    float speed = 30;  
    float2 uv2 = float2(in.uv.x, in.uv.y - mrate * speed);  
    float3 texColor = float3(matTexture.sample(textureSampler, uv2));  
  
    float4 col = float4(texColor, 0.8);  
     
     
    //  
    // 黒をdiscard  
    //  
    float threashold = 0.15;  
    if(col.r <= threashold && col.g <= threashold && col.b <= threashold)  
    {  
        discard_fragment();  
    }  
  
     
    //  
    // 地面側をdiscard  
    //  
    float yuv = (in.uv.y + 10)/20.0; // in.uv.y は -10〜10。 → 0〜1にする。  
    float edgeYuv = easeInQuad(mrate * 1.8);  
    float alphaLen = 0.05;  
    if(edgeYuv < yuv)  
    {  
        discard_fragment();  
    }  
     
    // 擬似透明ディザ  
    if(edgeYuv - alphaLen < yuv)  
    {  
        float alphaRate = (yuv - edgeYuv + alphaLen)/alphaLen; // 0(地面) - +xx (口)  
        alphaRate = 1.0 - alphaRate; // -xx(口) - +1(地面)  
        if(0 < alphaRate)  
        {  
            float rnd = rand4(float2(in.uv.x, in.uv.y));  
            if(alphaRate < rnd)  
            {  
                discard_fragment();  
            }  
        }  
    }  
  
  
    //  
    // 口側をdiscard  
    //  
    float kishimenLen = 2.2;  
    if(yuv < edgeYuv - kishimenLen)  
    {  
        discard_fragment();  
    }  
     
    // 擬似透明ディザ  
    if(yuv < edgeYuv - kishimenLen + alphaLen)  
    {  
        float alphaRate = (edgeYuv - kishimenLen + alphaLen - yuv)/alphaLen; // 0(口) - +xx (地面)  
        alphaRate = 1.0 - alphaRate; // -xx(地面) - +1(口)  
        if(0 < alphaRate)  
        {  
            float rnd = rand4(float2(in.uv.x, in.uv.y));  
            if(alphaRate < rnd)  
            {  
                discard_fragment();  
            }  
        }  
    }  
  
  
    return  col;  
}

metalとSceneKItは最初ビビってたけど、数週間触ってると、metalは GLSL だし、SceneKItはopenFrameworksぽいなと思い始めたら、そんなに怖くなくなった思い出。厳密には、違うんですけど。

USDZのアニメーション経過時間に合わせて、Swift 側で各種イベントが発火する仕組み

過去に経験した WebGL + Three.js + GLTF のような Web の 3D 開発環境だと、Animation 再生からの経過時間は AnimationAction の .time ていうプロパティから経過時間がとれて、毎フレーム監視しつつ、 特定の秒数になったらイベントを発火する、っていう書き方していたので。

swift にもきっとそういう時間経過がとれるプロパティがあるんだろうな〜、と目星をつけて、挑んだんですが、 SCNAnimation にはそういうプロパティが見当たらず。 developer.apple.com の SCNAnimation のページ には詳しいことは何も書いてないので、とにかく animationDidStart、animationDidStop、animationEvents.. などの使えそうな名前のプロパティを片っ端から1回叩いて挙動を観察したり、GitHub内を検索しまくりました。( Swift SceneKit を開拓した先人の痕跡があまりネット上になく、全体的にそういう事が多かったです。新しい言語に挑戦する時の心構え。。)

結果、以下のように書く事で、アニメーションの経過時間に合わせたイベントが発火できました。(シンプル)

SCNAnimationPlayer.animation.animationEvents = [  
            SCNAnimationEvent(keyTime: 0.1, block: { _, _, _ in  
                print("アニメーションが10%進んだ")  
            }),  
            SCNAnimationEvent(keyTime: 0.25, block: { _, _, _ in  
                print("アニメーションが25%進んだ")  
            })  
        ]  

また、 複数の USDZ (例: emoc と 虹ゲロ と建物など)の SCNAnimationPlayer を個別に同時に再生し始めた時に、各アニメーションの再生秒数がどんどんズレていかないかな?というのが心配で長時間検証したんですが。大丈夫そうでした。

ビルのモデル

emoc のアクションにあわせて、ビル側にもアクションがありました。QRコードエリアからスライムが登場したり、観音開きしたり、中央が割れたりしていました。

このモデルは Saqoosha さんのをベースに、 comomo 先生に emoc 用にカスタムして頂きました。

これを実現するためのビルモデルは以下のようなパーツに分かれていました。

  • 表面:観音開き用のドア、QRコード用のドア、飛び散る破片用
  • 裏側 : 観音開き用の部屋、QR用の部屋


実は裏側でいろんなパーツで構成されている見えないビルモデル

emoc のレンダリング設定について

emoc は以下のような方針でレンダリングしております。

  • キャストシャドウあり : ビルには影が落ちる
  • セルフシャドウなし : emocの影はemocには落ちない
  • ザラザラシェーダーあり : 以下の図のような影の落とし方

これは以下の設定をすることで実現してます。

ザラザラシェーダーは Creating a Risograph Grain Light Effect in Three.js ていうThree.js の記事を参考にしつつ、「emocの肌の法線」「ライトへの向きベクトル」の内積をもとに明るさを計算してました。


Creating a Risograph Grain Light Effect in Three.js 内の図を拝借

このような設定にすることで、太一さんが描かれていた emoc の絵に、CG 絵もある程度、近づけることができました。

左: 太一さんが描いた emoc、右: CG emoc

参考になった記事

SceneKit 情報がネットに少ないと思ってたら、Apple Engine さんという blog が日本語で一気に読めるように記事をたくさん書かれていて、入門者の私には、「神!!」となりました。SceneKit を理解する記事まとめ にはそんな Apple Engine さんの SceneKit にまつわる記事まとめがありました。

さいごに

六本木・乃木坂駅近くの Whererver ビルMoAR というアプリ は見れるから、天気予報を確認して、ぜひ晴れてる日に体験しにきてください! AppClip という仕組みのおかげで、iOS限定だけど AR をはじめるまでの起動体験がかなりスムーズなのが一番の売りでして、あまりまだ他事例もないので、ぜひお試しください!

おまけ : emoc のバグ絵

いろいろ検証していたら、emoc のおもしろバグ絵がいっぱいできるので、成仏のため2つだけ貼らせてください。emocかわいい。

Discussion