Unityで2D世界と3D世界を行き来するエフェクトをつくる

f:id:hirasho0:20190125205208g:plain

この記事では、上記動画のように、3D世界にいるキャラクターと、画面の上端と下端に貼りつけてある2Dの UIの間でエフェクトの行き来をする方法について、技術部平山が書いてみます。

実行可能なサンプルをwebに置いてありますので、 実行して動かしてみると良いかと思います。 ドラッグするとカメラが動き、タップしたりキーを叩いたりするとエフェクトが出ます。 エフェクトが出てからカメラを動かしても正しい位置に飛んでいくことをご確認ください。 コードも同様にgithubに置いてあります。

さて、描画を3Dで行うゲームであっても、表示する情報類は2Dのレイヤーを重ねて表示する、 というスタイルは良く見られます。 例えば弊社東京プリズンはそのスタイルです。

f:id:hirasho0:20190125205237j:plain

こういうゲームでは、たまに3Dと2Dを行き来するような表現が欲しくなることがあります。

例えば、キャラクターが何かの技を発生させて、その結果、 2Dで表示されているポイントが増えたり、ゲージが増えたり、 技カードの状態が変わったりすることがありますが、 バラバラに表示をすると、原因と結果の結びつきがわかりにくくなります。 そこで、キャラクターからゲージや数字、カードといった2D要素に向かって 何かを飛ばすわけです。動くものは目を引きますし、 動きの向きによって、原因と結果の関係が直感的に理解できます。 東京プリズンでは、「技を使った結果必殺技ゲージが溜まる」的な仕様がありまして、 その表現に使っています。

逆に、2DのUI要素を叩くことでキャラクターに何かが起こる、 というようなことがあれば、UI要素からキャラクターに向かって何かを 飛ばしたくなることもあるでしょう。 例えば東京プリズンでは、キャラクターがノックアウトされた時に、 画面上部にある「KOカウンタ」に向かって炎の玉が飛んで行ったり、 倒した敵からお金や宝箱が出て、それが画面上部のお金や宝箱のカウンタに飛んでいったりします。

人間が同時に追える動きは普通一つですので使いすぎは禁物ですが、 うまく使えばわかりやすい表現にできるかと思います。

さて、ではこういう効果を実装するにはどうすればいいのでしょうか。

全部3D世界に置く

一番理屈として簡単なのは、全てを3D世界に置くことです。 3D世界はワールド座標上に固定し、 例えば「戦場はX範囲-100から100、Z範囲-20から20に置く」 と決めます。 そして、2DのUIも同じ3D世界に配置すると考え、 カメラに対して固定した位置に、毎フレーム設置しなおします。 カメラが1m上へ上がれば、UIも1m上へ動かすわけです。 そうすれば止まっているように見えます。 車の中から外を見る時、窓ガラスに貼ったステッカーは 動かないように見えますが、車、そして車の中の人間と一緒に動いているわけです。

このやり方であれば、カメラが一つで良いので、 カメラを複数置くことによる性能の無駄がありません。 全てを3D世界に置けるので、頻繁に行き来がある場合には最もシンプルになります。 また、UIをカメラに対して傾けて遠近感を出したり、奥行き方向に動かしたりする 3D的な演出も制限なく行えます。一味違ったUIを作るには良い方法でしょう。

ただし、そこまでやるにはUIを全てSpriteRendererで作ることになり、 UnityEngine.UIの便利機能の恩恵を受けられません。 ピクセル単位で画像を配置できないのは相当に不便なはずで、 そこまではしたくない、というケースもあるでしょう。 この場合は、UnityEngine.UIで作り、ワールド座標でCanvasを置くだけで済ますこともできます。 カメラの子にcanvasを置けば相対位置が保たれますので、手間もさほど増えません。

しかし、東京プリズンの場合はこの方法を選択しませんでした。 すでに3D用と2D用のカメラを分けて作ってしまっており、 後からそこをいじるのが手間だったからです。 また、カメラを分けておけば 2D要素の上に3D要素がかぶさる心配をせずに済みますし、 UI要素をいくつかのシーンに分けて作ることも簡単です。 カメラの子にCanvasを置く必要がないからです。 シーンの数だけ「カメラの座標を参照して毎フレーム正しい位置に配置し直す」処理を行うのも面倒でしょう。

2D世界と3D世界を分けたままやる

というわけで、東京プリズンではカメラを分けたままでやることとしました。 これには大きく分けて二つの方法があります。

  • 飛ばすものを3D世界に置く
  • 飛ばすものを2D世界に置く

前者の場合、飛ばすものはSpriteRendererなりMeshRendererなりで作り、 3D世界の座標系で配置し、3Dのカメラで写します。 そして、例えば2D要素から出発したように見せるのであれば、 「2D要素が仮に3D世界にあるとしたらどこにあるか」を計算します。 そして、その座標から出発して3D世界の座標系で動かします。 3Dのカメラで描画する、つまりPerspectiveのカメラで描画するので、遠近法のケアは不要です。 遠くに行けば勝手に小さくなります。

後者の場合、飛ばすものはUnityEngine.UIで作り、2D用のカメラで写します。 UIは2Dの座標系で配置してあり、そのまま動かすと3D世界と馴染まないので、 同じように「2D要素が仮に3D世界にあるとしたらどこにあるか」を計算し、 3D世界の座標系で動かします。そして、 「その3D世界における座標が2Dのカメラから見たらどこに見えるか」を計算し、 Transformに入れるパラメータを計算します。 さらに、カメラからの距離によってスケールを調整することで、 「遠くに行くほど小さい」を手動で実現します。 2Dのカメラは正射影(Orthographic)なので、遠くに行っても小さくならないからです。 こちらの方がずっと面倒ですが、 UI要素との描画順を制御したい場合はこちらが必要です。

東京プリズンでは後者しか使っておりませんが、 今回のサンプルでは両方の実装を用意しました。

まずはサンプルの構造を

まず説明にあたって、サンプルの構造に簡単に触れさせてください。

シーンのオブジェクト木構造は以下のようになっています。

f:id:hirasho0:20190125205115p:plain

Sampleなるシーンの中に、3Dと2Dというオブジェクトが存在しています。 3Dの下にCamera3Dがあり、ステージやキャラクター、 そして「3D世界に置くエフェクト」を写します。 2Dの下にもCamera2Dがあり、Canvasと「2D世界に置くエフェクト」を写します。 2Dのカメラに3Dのものが写ったり、3Dのカメラに2Dのものが写ったりしないように、 2D世界のカメラはZを100ほどずらしてあります。

f:id:hirasho0:20190125205109j:plain

スクリプトはMainScriptオブジェクトについている Sampleクラスだけです。 今後示すコードは全てこの中にあるものとなります。

3Dの物を2Dに合わせて置く

サンプルでは、UIからキャラクターに向かって飛ぶエフェクトは、3D世界にあります。 画面上部のコインや箱、あるいは、画面下部の「バースト」とある丸いものがUIで、 ここからキャラクターへ向かって飛んでいきます。

これをやっているコードはSample.UpdateEffect3D() で、おおまかに言えば以下のような処理をしています。

  • 開始点のUIの3D世界における座標を得る
  • 開始点と目標点の間を補間する
  • エフェクトのTransformにワールド座標を設定する

一つづつ見ていきましょう

UIの3D世界における座標を得る

これを行うコードはSample.Calc3dWorldPositionOfUi です。

static void Calc3dWorldPositionOfUi(
    out Vector3 worldPositionOut,
    Transform uiTransform,
    Camera camera2d,
    Camera camera3d,
    float uiPlaneDistanceFromCamera)
{
    var worldPos2d = uiTransform.position; // 2D世界おけるワールド座標を得る
    var screenPos = camera2d.WorldToScreenPoint(worldPos2d); // スクリーン座標に変換
    var cosine = Vector3.Dot(ray.direction, camera3d.transform.forward)
        / camera3d.transform.forward.magnitude; // ray.directionの長さは1固定なので割らずに済む
    var distance = uiPlaneDistanceFromCamera / cosine;
    worldPositionOut = camera3d.transform.position + (ray.direction * distance);
}

内容を箇条書きにすると以下のようになります。

  • 開始点にあるUIの2D世界におけるワールド座標を得る
  • このスクリーン座標を得る
  • このスクリーン座標を3D世界におけるレイに変換する
  • レイ上の一点を選ぶ

2D世界におけるワールド座標の取得

Transform.positionで取れます。

2D世界におけるワールド座標→スクリーン座標

2Dカメラと3Dカメラをつなぐものは、スクリーン座標です。 カメラをまたぐ時は基本スクリーン座標を経由します。 「2Dカメラ上で画面のここに写る。3Dカメラ上で画面のそこに写ったものの、3D世界におけるワールド座標ではここ」 という順序で計算するわけです。 Camera.WorldToScreenPoint() を使います。

2Dのものも3Dのものもワールド座標を持っていますが、 2D世界のワールド座標は、2Dカメラにとって都合良く映る座標にすぎず、 3Dカメラから見る時には使えません。2DカメラがZ+100されていることを思い出しましょう。 なのでワールド座標であっても「2D世界における」「3D世界における」といった言葉をつけることにします (カメラが一つであればこういうややこしさがありません)。

スクリーン座標→3D世界におけるワールド座標系のレイ

スクリーン座標からワールド座標に変換するには、一旦レイを経由します。 なぜなら、「画面上のある点」は実際には点でなく直線だからです。 まっすぐな棒を目の前に前を向けて持てば、点に見えます。 その直線上のどこにある点なのか、 あるいは、カメラからどれだけの距離にある点なのか、は定まりません。 これを自分で決めてやる必要があります。

これには、 Camera.ScreenPointToRay() が使えます。使うカメラにはご注意ください。 3Dのカメラを使えば「3D世界におけるワールド座標系のレイ」が得られ、 2Dのカメラを使えば「2D世界におけるワールド座標系のレイ」が得られます。

3D世界におけるワールド座標系のレイ→3D世界におけるワールド座標

このサンプルでは、uiPlaneDistanceFromCameraという変数がこれを決めるために存在しており、 サンプルのGUI上でも実行しながら調整できます。 これは「コイン、箱、バーストの丸、といったものが乗る平面が、仮に3D世界にあるとしたら、 カメラからどれだけ離れた距離にあるか」 を示します。「UIまでの距離」ではなく「UIが乗る平面までの距離」 であることに注意してください。

ray.directionはカメラからUI要素に向かうベクトルで、 カメラからこのベクトルを伸ばした所に求めるべきUI要素があります。

f:id:hirasho0:20190125205112p:plain

カメラが向いている方向のベクトルはCamera.transform.forwardです。 UIが乗っている平面までの距離は、上述のuiPlaneDistanceFromCameraです。 知りたいのは、ray.direction をどれくらい伸ばせばUI要素に辿りつくかなので、 図中の「UI要素」と「カメラ」の距離が問題になります。

これは、ray.directionとcamera3d.transform.forwardのなす角(θ)の コサインがわかれば求まります。

distance * cosine = uiPlaneDistanceFromCamera

です。2つのベクトルA,Bの角度に関しては、内積を使って、

cosine = Vector3.Dot(A, B) / (A.magnitude * B.magnitude)

という関係があるので、ここから求まりますね。今の場合、 「Ray.directionの長さは1である」と公式のマニュアル にありますので、割るのはcamera3d.transform.forwardの方だけで良く、

var cosine = Vector3.Dot(ray.direction, camera3d.transform.forward)
    / camera3d.transform.forward.magnitude; // ray.directionの長さは1固定なので割らずに済む
var distance = uiPlaneDistanceFromCamera / cosine;

で距離が求まります。あとはカメラ座標にray.directionをこの長さだけ伸ばしたものを足すだけです。

worldPositionOut = camera3d.transform.position + (ray.direction * distance);

補間

次に、座標を補間します。

Vector3 pos;
pos = Vector3.Lerp(srcWorldPos, dstWorldPos, t);

サンプルのコードでは_useBezierなる変数で分岐していますが、elseの方だけ見てください。

出発点と目標点をLerpで線形補間して、 何があっても指定した時間で到着するようにしています。 遠いほど時間がかかるようにしたければ別の計算が必要で、しかも結構面倒です。 大抵の用途では、指定時間で必ず到着する補間の方が楽でしょう。

補間係数tは0から1の間である必要があるので以下のように求めます。

var t = effect.time / _effectDuration;

発生からの秒(effect.time)を、エフェクトの表示時間(effectDuration)で割れば、 0から1になります。

なお、_useBezierがtrueの場合、山なりの軌道にすべく、 ベジエの計算をしています。出発点と目標点の中点を5mほど上に上げて制御点を作り、 これを用いて軌道を作るわけです。 まっすぐ飛ぶよりはいい感じで、東京プリズンでもこうしています。 また、加速感が欲しいので、tを二乗することで最初をゆっくりにし、後半を加速させています。 本来ならパラメータを外に出して調整した方がいいのでしょうが、 「加速させるなら二乗」というのは簡単な割に使えますので、機会があれば 試されると良いかと思います。

Transformにセット

あとは、エフェクトのTransform.positionにできた座標をつっこみます。 エフェクトのGameObjectの上流のTransformが全てデフォルト値 (位置と回転が0、スケールが1)なのであれば、 positionをいじるよりもlocalPositionをいじる方が高速に同じ結果が得られる気がしますが、 確認はしていません。サンプルでは事故らないことを重視してpositionを設定しています。

2Dの物を3D的に置く

では本題の「面倒くさい方」に行きましょう。 UIが3D世界より手前にあるとみなす場合は 実のところ飛ばすものを3Dにして上のやり方をした方が楽だし、 それで十分なのですが、 「UIが3D世界より奥にあるかのように見せたい」場合には そうも行きません。

東京プリズンの 「倒した敵から画面上部のコイン数表示に飛ばす」という演出の場合で言えば、 上部UIは敵よりは奥にあるかのような動きにしたいし、 UIよりも手前に表示したいのです。こうなると2D層で描画する必要があります。 Unityにおいて安易にカメラを増やすと性能を劣化させますので、 描画順をいじるためだけにカメラを増やしたくはありません。

では処理を見ていきます。サンプルでは「キャラからUIへ飛ぶエフェクト」 がこの方法で実装されています。 コードはSample.UpdateEffect2D()のあたりで、おおまかには以下のような処理をしています。

  • 目標点のUIの3D世界における座標を得る
  • 開始点と目標点の間を補間する
  • 補間する
  • 補間後の3D世界におけるワールド座標をスクリーン座標に変換する
  • スクリーン座標を2D世界におけるワールド座標系のレイに変換する
  • レイの始点をエフェクトのpositionに設定する。
  • カメラからの「Z方向距離」を求める。
  • 距離からスケールを計算する
  • スケールを設定する

補間までは同じですが、出てきた3D世界におけるワールド座標を、2D世界に持っていかねばなりません。 そこの処理がひどく面倒くさいわけです。補間が終わったところから説明しましょう。

3D世界におけるワールド座標をスクリーン座標にする

2Dと3Dをつなぐものはスクリーン座標なので、 一旦スクリーン座標にしましょう。 使うカメラは3Dのカメラです。カメラを間違えると動きません。

スクリーン座標を、2D世界におけるワールド座標系のレイに変換する

スクリーン座標はCamera.ScreenPointToRay()でレイに変換できます。 使うカメラは2Dです。これで2D世界のワールド座標を得ます。

レイの始点をワールド座標として使う

2Dカメラは正射影(Orthographic)なので、 ScreenPointToRay()で得られるray.directionは全て(0,0,1)です。 確認してみると良いでしょう。 つまり、レイ上のどこを取ってもx,yは変わらず、画面上の位置は同じになります。 ならば、Ray.originを使うのが簡単でしょう。 これをエフェクトのTransform.positionに設定します。 このあたりのコードは以下のようになります。

var screenPos = _camera3d.WorldToScreenPoint(pos);
ray = _camera2d.ScreenPointToRay(screenPos);
effect.transform.position = ray.origin;

遠近法を自力でやる

2D(正射影)の場合遠くに行っても勝手には小さくなりません。 3D世界にあるように見せかけるなら、この拡大縮小の処理を手動でやる必要があります。

さて拡大率の計算ですが、普通の3D描画において、拡大率はカメラからの「Z方向距離」で決まります。 普通の距離ではありません。問題になるのはZだけです。 例えばカメラが(0,0,0)にあるとして、(80,90,100)までの距離は150ちょいですが、 今問題にしている「z方向距離」は100です。xとyは見ません。

なので、エフェクトの3D世界におけるワールド座標を、 ビュー座標系(=カメラのローカル座標系)に変換して、そのzを見る必要があります。 だいたいこんなコードでそれができます。

var posInViewSpace = _camera3d.transform.worldToLocalMatrix.MultiplyPoint(worldPosIn3d);
var zDistance = posInViewSpaec.z;

3DのカメラのTransformがworldToLocalMatrixを持っているので、それでワールド座標を変換し、 そのzがz方向距離となります。

が、いちいち行列を取ってきてベクトルを乗算する、などという遅そうな臭いのする計算をしなくても、 もっと簡単に求めることができます。図を見ましょう。

f:id:hirasho0:20190125205112p:plain

今はUI要素の位置がわかっているので、カメラとUI要素の距離は計算できます。 欲しいのは、UI平面とカメラの距離、つまり、camera.transform.forwardと 書いた方の線の長さです。 例によってコサインがわかれば出ますね。

var camToPos = pos - _camera3d.transform.position;
var cosine = Vector3.Dot(camToPos, _camera3d.transform.forward)
    / (camToPos.magnitude * _camera3d.transform.forward.magnitude);
var zDistance = camToPos.magnitude * cosine;

カメラからエフェクトのワールド座標に向かうベクトルcamToPosの長さ(magnitude)が、「普通の距離」です。 コサインを求めて乗ずればZ距離が出ます。

ここで、camToPos.magnitudeが分子と分母の両方にあるので省けますね。

var camToPos = pos - _camera3d.transform.position;
var zDistance = Vector3.Dot(camToPos, _camera3d.transform.forward)
    / _camera3d.transform.forward.magnitude;

普通は数学的な記法で式変形までしてからコードにしますが、 ブログ上で数学的な記法を書くのが面倒なのと、 コードの方が慣れてる方が多そうなので、コードにしてから変形してみました。

あとは、z方向距離からスケールを求めます。 このサンプルでは、z距離がuiPlaneDistanceFromCameraに等しい時に、 スケールを1にするようにしています。距離がその2倍であれば、スケールは半分の0.5としたいので 割ればいいですね。

var scale = effect.uiPlaneDistanceFromCamera / zDistance;
effect.transform.localScale = new Vector3(scale, scale, scale);

めでたく計算が完了しました。

なお、Transform.fowardは長さ1固定なんじゃないかという気がするのですが、 ドキュメント に明記されていないので割っています(下の方の例を見るに、長さ1を前提としているように見えるが...)。

おわりに

いかがでしょうか。大したことはしていませんが、 基礎的なベクトル操作と座標変換に馴染んでいないと、 日常的にこういう処理を書く気にはなれないように思います。 Unity時代になっても、ベクトルをわかってないとできないことは山ほどある、 ということがわかりました。

ちなみに、私は最近はベクトルを使わない仕事ばかりしていたため、 今回のサンプルを作るにあたって少々手こずりました。 定期的にベクトルを使う仕事をして、腕が錆びつかないようにしたいものです。

AfterEffectsで作ったものをUnity用に(半)自動変換する

ここでは、AfterEffectsにて作成したアニメーションをどうUnityで再生するか? について技術部平山が書いてみます。

コードは実際の製品で使っているものを、ほぼそのままgithubに公開しておりますが、 あくまでサンプルとお考えください。作った人間にしかわからない制限事項やクセが多くあり、ドキュメントもありません。

何を作ったか

まず下の動画をご覧ください。

f:id:hirasho0:20190121173700g:plain

ひどい素人動画ですね。これは元々AfterEffects上で作ったアニメーションを、 今回紹介するツールにて変換してUnityで動くようにしたものです。 「ただ最初から最後まで再生するだけで良く、対応機能の範囲であれば」という条件はつきますが、 変換作業は数分でできます。 この例では動くものが数個しかありませんが、 これが数十個になっても、アニメーションが1分を超える大作になっても、 さほど手間は変わりません。

では、なぜこんなものを作ろうと思ったのかをお話します。

動機

以下の動画をご覧ください。

f:id:hirasho0:20190121173750g:plain

弊社東京プリズンのバトル開始アニメーションです。 炎に彩られたエンブレムが左右から現れ、「VS」の表記が中央に現れ、 バトルに参加するキャラクター3人づつが左右から入り、 クルクル回るコインが登場して、先攻後攻が定まり、 最後に勝利条件が下からせり上がってきます。

さて、これをどうやって作りますか?というお話です。

元々の素材はAfterEffects上でアーティストによって作られています。 これをどうにかしてUnityに持ってこなくてはなりません。

「最初からアーティストがUnity上で作ればいいじゃん」 とお思いでしょうか? なるほど、それができる現場、それが適している現場もあると思いますが、 そうでない現場もあります。

それに、「アートのツールは手に馴染んだものを使う方が良い」と考えています。 学習コストの話ではありません。 結局ゲーム開発のコストの多くは 絵素材やアニメーションの量産にかかり、かつ、そこの品質は決定的に重要です。 効率の意味でも、アーティストは気持ち良く使えて手に馴染んだツールを使うのが良く、 それがAfterEffectsだと言うのであればAfterEffectsを、 Flashだと言うのであればFlashを使うのが良いように思えます。 あとのことはプログラマが技術によってどうにかすればいいのです。 ゲームの規模にもよりますが、現代的な量産規模を考えると その方が安いことが多いと考えます。

また、Unity上だと何でもできてしまうのが、ちょっと気になります。 複数人でシーンをいじった時の衝突問題もありますし、 スマホ向け開発ではアセットバンドルにコードを入れられない、という制限もありますから、 あまり何でもできてしまうと事故の原因になります。 性能的に望ましくない作り方や、管理上良くない作り方もあるでしょう。 その意味でも、外部ツールで作ってから変換するアプローチならば安全性を担保しやすいのです。 使われたくない機能は「ごめん、それ対応してない」と言えば済みますし、 変換ツールが検査ツールも兼ねていれば、例えば「オブジェクト多すぎる」「容量多すぎる」 といった警告を出すこともできます。

手動か?自動か?

そういうわけで他ツールで作ったものを持ってくる方法ですが、大きく分ければ選択肢が二つあります。

  • 動画を見ながらUnity上で手動で再現する
  • 何か素敵なツールを作って自動で変換できるようにする

それぞれに長所や短所があるのでまとめてみましょう。

手動の長所は初期コストと柔軟性、そして性能です。 凝った仕掛けを作る時間は必要なく、いきなり UnityEditor上でモノを置いていけます。 動きは、AnimationなりTimelineなりDOTweenなり、 好きな手段を使えば良いでしょう。 私は最初はDOTweenでやっていました。先輩がそうしていた、 というのが理由ですが、コードなら何でもできる、という点も大きいです。 Unityの旧Animationには「木構造をいじると参照が外れる」という辛い制約があり、 どうにも使いにくかった、ということもあります。 また、プログラマはアーティストよりも性能面に詳しいはずですから、 より高速に動作し、容量も少ない実装になる期待が持てます。

そして短所は、面倒くささ、再現度の低さ、そして変更への弱さです。 普通プログラマはアートに関しては素人です。 動画を見て、「位置や回転、拡縮の数字をどういじれば同じ動きになるのか?」 と考えるのは結構大変ですし、細やかな違いもわかりません。 プログラマ的に「こんなもんだろ」と思っても、アーティストから見たら全然ダメで、 「ここの動きはもっとシュッとしてバーンって感じなんだよ!!」みたいなフィードバックが 何度も来るかもしれません。手間が増えます。 そしてこの手間は、アーティストがアニメをいじる度に発生します。 現実には納期や工数が問題になりますので、もっとかっこいい動きに直したい、 と思ってもそう簡単には直せないでしょう。 つまり、本質的に高コストで、しかも品質も低くなりがちだということです。

さて、自動化するとどうなるか。単純にこの逆です。

初期コストがかかり、柔軟性が失われ、 よほど良くできていない限り、プログラマが最適なものを作るよりは性能が落ちます。 そのかわり、ツールによる自動変換であれば再現性の問題は解決し、 変換作業が十分に楽になっていれば、 アーティストが何度いじってもさほどの手間なくUnity側にも 反映させることができます。

では、平山が今回取った方法を紹介いたしましょう。

今回の手法の概要

結論から言えば、手動と自動の間です。

変換ツールは作りましたが、それで何でもできる完成度までは持っていきませんでした。 マスクに対応していない、シェイプに対応していない、 アニメーションが線形補間しか対応していない、 描画順がUGUIと異なると面倒くさい手作業が発生する、 等々の制限を敢えて残しています。 現場でUI実装タスクをこなす合間に作っていたので、 それらの機能まで作り込む時間はかけられなかったからです。

変換ツールによって生成されるのは、コードと、シーン上のオブジェクトです。 コード主体なので柔軟性は確保できます。 例えば「動的に画像をさしかえる」「ある画像がある場所に別のプレハブをはめこむ」 といったことは容易にできます。 Animationのアセットを生成する方が性能は出るでしょうけれども、 今回は柔軟性が欲しかったのでコードとしました。 上の動画の例でも、キャラの絵やコインの行き先、エンブレム、炎の色、 などは動的に変更されており、これは単にコードを書き加えることで実現しています。

ただし、コードですのでアセットバンドルには入れられません。 つまり、アプリ更新なしで新しいアニメーションを追加することはできない、ということです。 東京プリズンでは、アセットバンドルだけで追加したい場合は Spineを使っています。例えばエフェクト類です。 できればAfterEffectsからのデータも アセットバンドルに入れられるようにしたかったのですが、 時間もないし、ツールを保守するコストも払えないので、 そこはSpineに任せることにしました。

再現度については、動きをそのまま持ってこられますので、 対応機能の範囲であれば完全に再現できます。 どれだけ複雑で物が多くアニメが長くても作業にかかる手間は変わりません。 アーティストの変更に対応するコストも完全な手作業よりはかなり抑えられます。

では、作業の流れを軽くご覧いただきましょう。

実際の作業の流れ

ライブラリをUnityにインポート

事前に、再生側ライブラリ をUnityに入れておきます。これは一回だけです。

AfterEffectsでスクリプト実行

変換を始めます。 AfterEffectsのプロジェクト(サンプルにも入っています)を開き、

f:id:hirasho0:20190121173657j:plain

用意したスクリプト(AfterEffectsToUnityCodeConverter.jsx)を実行します。

f:id:hirasho0:20190121173822j:plain

ここで「実行」を押すと、保存するファイル名を聞かれ、保存すると、 C#のコードが出てきます。 出てきたファイルがこちらです。 これをUnityにインポートします。

この際、必要ならばクラス名やファイル名を変えたり、名前空間のusingを足したりしてください。 名前空間に関しては、C#生成スクリプトのこのあたりを いじっておく方がいいと思います。 また、クラスはコンポジション(AfterEffectsの再生単位)ごとに生成されます。 プロジェクトに複数のコンポジションがあると、1ファイルに複数クラスのコードが入ってしまうので、 必要なものを切り出す必要があります(ファイルを複数書くのが面倒くさかったのです)。

絵素材をUnityにインポート

必要な絵素材をUnityのプロジェクトにインポートします。残念ながら手動です。 今回の例では、「人.png」「背景.png」「鳥.png」「ヒット.png」の4枚を用意しました。

シーンにgameObjectを生成、コンポーネントの設定

シーンの中にこのアニメのインスタンスを生成しましょう。 Canvasの下にテキトーなGameObjectを作って、 さっき出てきたスクリプトをつけます。

f:id:hirasho0:20190121173824j:plain

そして、Inspectorに絵素材を設定します。 大抵は同じ名前のところに入れますが、それは場合によります。 これが面倒くさいのでどうにかしたいのですが、 「この画像だけは縮小版を使う」「この画像は以前からゲームに入っているのでそっちを使う」 「この画像はダミーで実際はこっち」といったことが頻繁にあり、 現状は手動です。

f:id:hirasho0:20190121173829j:plain

オブジェクト階層を生成、テスト再生

エディタ拡張でつけた「オブジェクト生成」ボタンを押します。 すると、GameObjectの階層と、絵素材がセットされたImageコンポーネントが自動生成され、 GameObjectの名前がクラス名になります。

f:id:hirasho0:20190121173835j:plain

実行してInspectorの「初期化」ボタンを押し、「再生」ボタンを押せば、再生できますが、 実際に使う時はプレハブにするのが良いでしょう。 どこかでInstantiateして、AfterEffectsAnimation.Play()を実行すれば再生されます。

f:id:hirasho0:20190121173728g:plain

加えて、必要であればコードをいじっていろいろします。先に紹介したバトル開始画面は いくつかの部品に分かれていて、それぞれをプレハブにし、 また、動的に置き換えるテキストはImageをTextに置き換える改造を手動でやっています。 これもどうにかしたいところですね。

実装

AfterEffects上のツール

AfterEffectsで実行する部分についてはJavaScriptで実装する必要があります。 JavaScript未経験であれば覚えないといけませんが、 JavaScriptは「とりあえず動くものを書くだけ」なら簡単な言語です。 普段C#で書けている人なら書けるでしょう。 C#やJavaのように型システムが厳格な言語ばかりやっていると だいぶ感覚が違って面白いので、世界を広げる上でもやっておいて損はないと思います。

なお、あまり新しい書き方をするとAfterEffects上で動かないので、 「今時のJavaScript」を覚える必要はないかと思います。「クラス?何それ?」です。

AfterEffectsからデータを抜くAPIについては、 公式文書 を読んで試しながらやれば、一日で感じは掴めると思います。 私のサンプルコードがお役に立てるかもしれません。

開発の段取りについて

AfterEffects上での実行はデバッグが大変です。 そこで、AfterEffectsに依存した部分と、依存しない部分に分け、 依存した部分を最小にする、というのはいかがでしょうか。

今回の実装では、 拡張子がjsxのこのファイル がAfterEffects上でしか動かない部分で、 拡張子がjsのこのファイル が他でも動く部分です。

まず、「AfterEffectsからありったけの情報を引っこ抜いてJSONにする部分」は 絶対にAfterEffects上でしか動かないので、これはjsxに書きます。 そしてJSONファイルが得られた後は、どこで実行してもかまいません。 私はchrome上で実行、デバグしていました。

今回のツールの開発は、現場で日々UIの実装要求が来る中で勝手に(=スクラムのタスクに計上せずに) やりましたので、まとまった時間は取れません。そこで、段階をかなり分割して開発しました。

  • まずJSONに吐くところまで。それを見ながらDOTween.SequenceのInsert()の時刻と値を手打ち
    • この段階で再現度の問題は解決します。
  • ランタイムライブラリのDOTween版を用意
    • Imageの拡張メソッドでInsertできるようにして簡単に書けるようにした、というだけ。
  • JSON内のアニメからDOTweenのInsertを生成する部分だけブラウザ用に開発。
    • この段階でキーを見ながら打ち込む手間が消滅
  • DOTweenでは速度の問題があるので、キーフレームアニメーション版のランタイムを用意
    • DOTweenだと再生時に毎度データを生成するのでGC Allocがヤバい。
  • これを使うようにJSONからC#を生成するJavaScriptをブラウザ用に開発。
  • 「オブジェクト生成ボタン」をエディタ拡張で用意
    • 元の木構造を手で再現する手間が消滅
  • ブラウザ実行部分をAfterEffects上で一気に実行できるように統合
    • JSONを吐く機能もデバグ用に残してある

こんな具合です。各段階で、それ以前よりも楽になるようにしないと、 現場でのツール開発は難しいなと感じます。 途中で中断しても、そこまでで使えるようにしておくのが良いでしょう。 各段階は「なんとなく動く」にせいぜい1日、あとは隙を見てバグをつぶしたり 機能を足したり、という感じでした。

チーム内、あるいはチームをまたいだ形でツール作成の専門家がいる、 という体制もあるかと思いますが、 現場固有のニーズに応えるのが困難だったり対応が遅くなったりしますので、 必ずしもそれが良いとは言えないでしょう。

Unity側

Unity側のランタイム は以下のようなクラス構成です。

  • AfterEffectsAnimation
    • アニメーションインスタンスの基底クラス。MonoBehaviour。再生などのエディタ拡張とコールバック類。
  • AfterEffectsInstance
    • アニメーションインタンス再生処理の実装部分を受け持つ。gameObjectと個別のキーフレームアニメーションを接続して再生する。
  • AfterEffectsResource
    • アニメーションにつき一つだけあるデータ。リソースあるいはアセット。AfterEffectsCurveSetを持つ。
  • AfterEffectsCurveSet
    • Vector2、float、boolの時間推移(Curve)を複数束ねたクラス。
  • AfterEffectsCurve
    • Vector2、float、boolそれぞれの時間推移をキーフレーム配列として持ち、時刻を与えると値を返す。

ツールが生成するC#はAfterEffectsAnimationの派生クラスであり、 AfterEffectsResourceを生成するコードも一緒に生成されます。

public static void CreateResource()
{
    _resource = new AfterEffectsResource(30f);
    _resource
        // layer1_人_png
        .AddPosition(
            "layer1_人_png_position",
            new int[] { 0, 90, 107, 117, 131, 147, 162, 169, 179, 299 },
            new float[] { 52f, 242f, 282f, 308f, 366f, 438f, 474f, 508f, 532f, 678f },
            new float[] { 276f, 294f, 188f, 158f, 148f, 206f, 278f, 246f, 290f, 270f })
        .AddRotation(
            "layer1_人_png_rotation",
            new int[] { 0, 90, 108, 131, 169, 178 },
            new float[] { 20f, 0f, -43f, -12f, 0f, 13f })
        // layer2_鳥_png
        .AddPosition(
            "layer2_鳥_png_position",
            new int[] { 0, 54, 90, 120, 139, 209, 299 },
            new float[] { 660f, 489.861051392424f, 412f, 349f, 205.589212215198f, 115f, 23f },
            new float[] { -20f, 71.5331734089565f, 109f, 120f, 103.796352593026f, 141f, 273f })
        .AddScale(
            "layer2_鳥_png_scale",
            new int[] { 0, 54, 90, 119, 139, 209, 299 },
            new float[] { 10f, 30f, 60f, 100f, 50f, 30f, 10f })
        .AddRotation(
            "layer2_鳥_png_rotation",
            new int[] { 120, 299 },
            new float[] { -18f, 3600f })
        // layer3_ヒット_png
        .AddScale(
            "layer3_ヒット_png_scale",
            new int[] { 120, 121, 125, 132 },
            new float[] { 100f, 200f, 100f, 40f })
        .AddRotation(
            "layer3_ヒット_png_rotation",
            new int[] { 120, 132 },
            new float[] { 26.8260869565217f, 101.8261f })
        .AddOpacity(
            "layer3_ヒット_png_opacity",
            new int[] { 0, 119, 120, 131 },
            new float[] { 0f, 0f, 100f, 0f })
        // layer6_背景ルート
        .AddPosition(
            "layer6_背景ルート_position",
            new int[] { 0, 299 },
            new float[] { 320f, -320f },
            new float[] { 200f, 200f })
        .AddCut("", 0, 300);
}

日本語が混ざってますが、これはAfterEffectsのレイヤ名がそうだからです。 コードに日本語が入ることが許せない人は、アーティストさんに名前のつけ方を変えてもらうか、 自力で直すことになりますが、私はどうでもいいと思います。 昔のC++と違ってUnicodeなので、文字コード由来でバグることはありませんし、 所詮自動生成コードなので、手でメンテナンスすることはあまりないからです。

さて、「layer1_人_png」の例で言えば、時刻0でxが52、時刻90でxが242、といった具合のアニメーションデータを AfterEffectsResourceに設定しています。 コードなので、「位置ずらそう」と思ったらここを書き換えればいいわけです。勝手に透明度のアニメを足したりもできます。

これで「何何という名前のノードに位置のこんなアニメがついている」 という情報が定義できたわけですが、再生する時には「それをどのgameObjectに適用するか」が わからないといけません。このためのコードも自動生成されます。

protected override void InitializeInstance(AfterEffectsInstance instance)
{
    var layer1_人_png_transform = _layer1_人_png.gameObject.GetComponent<RectTransform>();
    var layer2_鳥_png_transform = _layer2_鳥_png.gameObject.GetComponent<RectTransform>();
    var layer3_ヒット_png_transform = _layer3_ヒット_png.gameObject.GetComponent<RectTransform>();

    instance
        // layer1_人_png
        .BindPosition(layer1_人_png_transform, "layer1_人_png_position")
        .BindRotation(layer1_人_png_transform, "layer1_人_png_rotation")
        // layer2_鳥_png
        .BindPosition(layer2_鳥_png_transform, "layer2_鳥_png_position")
        .BindScale(layer2_鳥_png_transform, "layer2_鳥_png_scale")
        .BindRotation(layer2_鳥_png_transform, "layer2_鳥_png_rotation")
        // layer3_ヒット_png
        .BindScale(layer3_ヒット_png_transform, "layer3_ヒット_png_scale")
        .BindRotation(layer3_ヒット_png_transform, "layer3_ヒット_png_rotation")
        .BindOpacity(_layer3_ヒット_png, "layer3_ヒット_png_opacity")
        // layer6_背景ルート
        .BindPosition(_layer6_背景ルート, "layer6_背景ルート_position")
    ;
}

AfterEffectsInstanceという、再生インスタンスが個別に持つオブジェクトに対して、 BindXXXを呼びます。第一引数はアニメをつけるインスタンスの指定(Transform、CanvasGroup、GameObject等)で、 第二引数は名前です。これでgameObjectとアニメデータを紐付けます。 ここもコードなので、不要な動きをコメントアウトで消したり、勝手に足したりできます。

Curveの実装

次に、「時刻0で4、時刻10で10」のようなキーフレームデータの格納と再生についてです。 これを司るのはAfterEffectsCurveです。なんとなくstructにしていますが、classでも大差ないでしょう。

public struct AfterEffectsCurveFloat
{
    private short[] _times;
    private float[] _values;
    public AfterEffectsCurveFloat(
        IList<int> times,
        IList<float> values,
        bool isPercent = false,
        bool removeLeadTime = false)
    {
        Debug.Assert(times.Count == values.Count);
        int timeOffset = (removeLeadTime) ? -times[0] : 0;
        int count = times.Count;
        _times = new short[count];
        _values = new float[count];
        int prevTime = -0x7fffffff;
        for (int i = 0; i < count; i++)
        {
            Debug.Assert((times[i] >= -0x8000) && (times[i] <= 0x7fff));
            Debug.Assert(times[i] >= prevTime, "times must be sorted in ascending");
            _times[i] = (short)(times[i] + timeOffset);
            prevTime = times[i];

            _values[i] = values[i];
        }
        if (isPercent)
        {
            for (int i = 0; i < count; i++)
            {
                _values[i] *= 0.01f;
            }
        }
    }
    public float Get(float time)
    {
        int index = AfterEffectsUtil.FindLargestLessEqual(_times, time);
        float ret;
        if (index < 0)
        {
            ret = _values[0];
        }
        else if (index >= (_times.Length - 1))
        {
            ret = _values[_times.Length - 1];
        }
        else // 補間するよ
        {
            float t0 = (float)(_times[index]);
            float t1 = (float)(_times[index + 1]);
            float v0 = _values[index];
            float v1 = _values[index + 1];
            float t = (time - t0) / (t1 - t0); //[0, 1]
            ret = ((v1 - v0) * t) + v0;
        }
        return ret;
    }

時刻と、それに対応する値を単に配列に入れて初期化し、 再生時にはFindLargestLessEqual() という二分検索関数でデータを見つけて補間します。 例えば時刻3と時刻7のデータがあって、時刻が5であれば、 時刻3のデータと7のデータを半々に混ぜて返します。

ここでの作り方にはいくつかの選択肢があります。

  • ソートした配列で二分検索
  • ソートした配列だが前の時刻を覚えておいて順に見る

ソートした配列で二分検索する場合、毎度log Nの計算量が必要なので、 速いかと言えば微妙です。ただし、格納に必要なメモリ量は最小で済みます。 なお、二分検索と言っても「ある値を持つデータを見つける」ではなく、 「ある値以下であるような最大のデータを見つける」ことが必要です。 前の例なら、時刻5のデータはないので、その直前の3を見つけねばなりません。 Dictionaryはこの用途では使えません。

もう一つ、前に来た時刻とデータの番号を覚えておいて、そこから見る、という工夫もありえます。 前から順番に再生している場合は、多くの場合同じキーか次のキーを使うため、 二分検索のO(log N)の手間が不要になり、O(1)になります。

しかし、覚えておくデータが増えますし、 「一定以上離れた時間が来たら二分検索に回す」という処理を入れないと キーが多い際に途中からの再生が遅くなります。 前述のように手間をかけられなかったこともあり、単純な二分検索としました。 今まで何度かこういう処理を書きましたが、 ここの二分検索の性能が問題になったことはなかった、ということもあります。

AfterEffectsのデータをRectTransformに入れる

あとは、AfterEffects上の位置やアンカー、回転などのデータを Unityの仕様に合わせる処理が必要です。 これはAfterEffectsUtil.Set()にあります。

public static void Set(
    RectTransform transform,
    float anchorX = 0f,
    float anchorY = 0f,
    float positionX = 0f,
    float positionY = 0f,
    float scaleX = 100f,
    float scaleY = 100f,
    float rotation = 0f)
{
    // OnValidateだとAwakeが終わっていなくて呼ばれないことがある
    if (transform == null)
    {
        return;
    }
    // 左上原点に変更
    transform.anchorMax = new Vector2(0f, 1f);
    transform.anchorMin = new Vector2(0f, 1f);
    // 基準点設定
    var size = transform.sizeDelta;
    float pivotX = anchorX / size.x;
    float pivotY = 1f - (anchorY / size.y);
    transform.pivot = new Vector2(pivotX, pivotY);
    transform.anchoredPosition = new Vector2(positionX, -positionY);
    transform.localScale = new Vector3(scaleX * 0.01f, scaleY * 0.01f, 1f);
    transform.localRotation = Quaternion.Euler(new Vector3(0f, 0f, -rotation));
}

AfterEffects上ではスケールや不透明度はパーセントであるため、 0.01を乗じる必要があります。 また、左上原点なのでanchorMaxやanchorMinも設定します。 さらに、AfterEffects上はY軸が下向きなのでY方向の移動は反転する必要があります。 回転角度も逆です。 一番面倒なのはAfterEffectsのアンカーと、RectTransformのpivotの関係です。 これに関して説明すると長くなるので、上記のコードをご参照ください。

SpriteRendererでやりたい時は?

UnityEngine.UIでなくSpriteRendererを使いたい、 ということもあるでしょう。エフェクトの類では特にそうだと思います。 しかしこれが面倒なのです。実は対応していた時期もあって関数も残っているのですが、 たぶん今は動かないと思います。

AfterEffectsのアンカー設定を反映させるために、余計なTransformが必要で、 「1オブジェクトにつきGameObjectが2個」という状態になります。 UnityのTransformに直接行列を設定できれば簡単なのですが、やる方法が見つからないので 現状放置している感じです。 実際的には、SpriteRendererでやるならば、そもそもGameObjectを生成しないアプローチで やる方が良い気がします。 直接Meshを生成してMeshRendererなりで描画するのです。おそらくその方が性能も出るでしょうし、 AfterEffects上の描画順を手作業なしで反映させたいと思った時にも、その方が楽かと思います。

描画順について

UnityEngine.UIは描画順が親子関係によって決まってしまいます。 しかし、AfterEffects上では描画順は親子関係と関係なく決められます。 そして、今回のツールはここのケアをしていません。 ですので、そのまま移しただけでは描画順が狂うことがよくあります。

アーティストさんには「こういう親子関係でこういう順序ならそのまま出る」 とは説明しますが、前述のように、本来アーティストさんは作りやすいように作った方が良いわけで、 そこはプログラマがどうにかすべきでしょう。

ですが、現状何もできていません。描画順が違うところはプログラマ(つまり私)が、 手動で親子関係をいじったりgameObjectを複製したりしています。

現状このツールに関してはこれが一番大きな問題でしょう。 完全に解決するには、

  • UnityEngine.UIを使わずSpriteRendererでソートオーダー指定する
  • Transformの親子関係をフラットにして描画順に並べ、自前で位置、回転、スケールを計算する

のいずれかになりそうです。前者はボタンなどのUI要素を後から足すのが困難になり、 後者は「このノードから下の透明度のアニメを後から加えるのでCanvasGroup足す」 というような改造がしにくくなって拡張性が落ちますし、 元の親子関係がシーンに反映されず保守性も落ちます。

もしかしたら、SpriteRendererを使って、ボタンやテキストなどのUIシステムを 丸ごと作った方が早いのかもしれません。 うまくEventSystemに乗せられる形にし、 プロジェクトで使う範囲の機能に絞ってやれば、 それほどの実装コストにはならないような気もします。

まとめ

何を作るか、誰が作るか、等々によって最適な解は違うと思うのですが、 今回は「現場で片手間でやれる範囲で自動化する」というアプローチで やってみることにしました。 完全に自動にならない、アセットバンドルに入れられない、 等の制限もありますが、妥協して運用しております。

しかし、製品の規模がもっと大きくなると、アセットバンドルに入れられないとか、 完全にアーティストだけで作業が完結しないとかいったことは受け入れられないでしょう。 また、AfterEffects使いがおらずFlash使いがいる現場ならば、データ元はFlashになるでしょう。

そういったこともあって、今回公開したツールがそのままお役に立てるとは思いませんが、 こういったツールを作る際の、動機、段取り、設計、 といったものについて何かしら参考になれば幸いです。