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時代になっても、ベクトルをわかってないとできないことは山ほどある、 ということがわかりました。

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