Unityの複数解像度SafeArea対応で楽をするためには

モバイル向け開発は機械が様々です。 解像度は様々、アスペクト比も様々です。 最近は一部画面が欠けていて、ある範囲(SafeArea)に収めないと映ること保証しないよ、 という機械まで出てきました。

さて、これにどう対応するのが楽か?

技術部平山が、 弊社東京プリズンで行った手法をベースに、一つの例を示します。

こんなの

ここではこんな例を見てみます。

f:id:hirasho0:20190125212407j:plain

この画面には5つの要素があります。

  • 緑の楕円ウィンドウ。上寄せでsafeArea内にボタンを置きたい。画面幅に対する比率を一定にしたい。
  • 青の楕円ウィンドウ。左寄せでsafeArea内にボタンを置きたい。画面高さに対する比率を一定にしたい。
  • 赤の楕円ウィンドウ。safeArea内の中央に置きたい。どんな解像度でも画面内に収めたい。
  • 右のキャラ絵。safeArea無視して右下寄せで置きたい。どんな解像度でも画面内に収めたい。
  • 背景。画面全域を埋めたい。

この実解像度(Screen.width及びScreen.height)は1280x720の16:9です。 よくある解像度ですね。

さて、これを、4:3の機械(iPadとか)に持っていきます。 safeArea外の領域はなく、解像度は1024x768としましょう。

f:id:hirasho0:20190125212400j:plain

高さが増したので、青楕円が大きくなっています。「縦幅の半分」という定義だからです。 一方、キャラ絵が画面に占める比率は小さくなりました。

次は、2436x1125で画面端が欠けた端末にしてみましょう。iPhoneXです。 左右に132ピクセル、下に63ピクセルはモノを置くと危険な場所です。

f:id:hirasho0:20190125212412j:plain

周囲の赤い枠は、切り欠き等で使えない部分だと思ってください。 いわゆるsafeAreaの外です。 青楕円はボタンがSafeAreaの左端に寄せて配置され、楕円は その外にも描画されています。safeAreaとはいえ、 そこで切れてしまったらかっこ悪いからです。 キャラ絵は今はsafeArea無視で右下寄せで配置しているので、 いくらかがsafeArea外に出ています。 実際の製品ではキャラ絵は中に入れた方がいいかもしれませんね。

ついでなので、もっと極端な解像度も試してみましょう。432x768、 つまり縦長です。

f:id:hirasho0:20190125212355j:plain

せっかくなので、左10、右20、上30、下40ピクセルをsafeArea外としてみました。 青がもっと大きくなっています。緑が小さくなったのは「幅は画面幅の半分」 という定義だからです。キャラ絵も小さくなっています。 さすがにこうなると手間ゼロで対応、というわけには行かなそうですね。 しかし、このケースですら、 「画面からはみ出る」「いけないものが映ってしまう」「塗り残す」 といったことは起こっていません。

さて、当然ですが、この4つの解像度のスクショを取る際に、 いちいちレイアウトをいじったりはしていません。 GameViewの解像度を変え、inspectorにsafeAreaを定義する数個の数字を入れただけです。 弊社東京プリズンでは、こういったレイアウト要素の拡大縮小と配置を 自動化するコンポーネントを使っており、 多解像度対応のコストを減らしています。

今回はこの実装と運用について書いてみようと思います。なお、 コンポーネントのソースコード を含めた サンプルコードはgithubにて公開しています

今回紹介するものの使用法

おそらくゲームを作っている人ならば、この手のものを何かしら作っているとは思うので、 今回のコードの使い方を細かく説明しても意味はないのですが、 使い勝手からわかることも多々あるかと思いますので、軽く説明いたします。

なお、ここで紹介するものは東京プリズンでの実装をベースにしてはいますが、 そのままではありません。サンプルとして切り出すために変更した点や、 より改良した点があります。

まずコンポーネントをつける

まずやることは、RectTransformScalerというコンポーネントを、 レイアウト要素の根にあたるgameObjectにつけることです。 例として左寄せの青い楕円について見てみます。

f:id:hirasho0:20190125212417p:plain

LeftAlignedというgameObjectにコンポーネントをつけています。 この子に青い楕円や、ボタン等が配置されています。

では、一つづつ設定変数を見ていきます。

RectTransformのwidthとheight

LeftAlignedのwidth/heightは1280と720となっており、 「LeftAligned以下は1280x720の仮想解像度でレンダリングする」 ことを示します。実解像度が1920x1080であれ、1024x768であれ、 ほどよく拡大縮小+位置調整されます。 LeftAligned以下は画面が1280x720であるかのように 考えてレイアウトすれば良いわけです。

Scale Mode

拡大縮小を何に基いて行うかです。ここではVerticalとなっており、 「画面の縦幅を基準として行う」ということになります。 仮想解像度は1280x720で、縦は720なので、 実解像度がいくらであれ、それが720に相当するように拡大縮小がかかります。 例えば1920x1080であれば720を150%拡大して1080の大きさに描画し、 854x480であれば720を66%に縮小して480の大きさに描画します。

設定項目は4つあり、

  • Horizontal: 親の幅を基準にする。
  • Vertical: 親の高さを基準にする。
  • Min: 親の幅と高さの小さい方を基準にする。
  • Max: 親の幅と高さの大きい方を基準にする。

となっています。緑の楕円はHorizontalなので、 幅に合わせて拡大率が決められます。 赤い楕円はMinなので、縦長ならば幅に、横長ならば高さに合わせられます。 この結果、どのようなアスペクト比になっても全域が画面に入り、決してはみ出しません。 そして背景はMaxなので、縦長ならば高さに、横長ならば幅に合わせられます。 この結果、どのようなアスペクト比になっても、画面全体を塗りつぶし、ほぼ常にどこかがはみ出すことになります。

Horizontal Anchor

幅が余った時に左右どちらに寄せるかです。 例えば、width/heightが1280x720で、実解像度が2436x1125の時、 仮にScaleModeがVerticalであれば、720を1125に拡大する156%の拡大率となります。 結果、幅の1280は2000となり、436ピクセルほど余ります。 この時に、左に寄せるか、右に寄せるか、中央に置くかが選べるわけです。

青い楕円(LeftAligned)の例であれば、これがLeftですので左寄せとなります。 iPhoneXのように横長な端末で左寄せにする際に使うわけです。 一方キャラ絵はRightになっており、右寄せとなります。

Vertical Anchor

高さが余った時に上下どちらに寄せるかです。 例えば、width/heightが1280x720で、実解像度が1024x768の時、 仮にScaleModeがHorizontalであれば、1280を1024に縮小する80%の縮小率となります。 結果、高さは576となり、192ほど余るわけです。

緑の楕円はTopなので、上寄せとなります。 キャラ絵はBottomになっており、下寄せになります。

Use Margin

要するにsafeAreaを見るかどうかです。 例の要素の中では、青と緑と赤の楕円がtrueになっており、 赤い枠の中に収まるようにレイアウトされます。 青い楕円の左端が収まっていないのは、 元々楕円を1280x720の仮想領域からはみ出させて配置しているからです。 こうしておくことで、 16:9や4:3の端末では端が切れるが、より横長のiPhoneXのような 端末では隠れていた端が出てくる、といったレイアウトが可能になります。

大抵の場合16:9を基準にゲームの画面を作ると思うのですが、 例のように枠なりウィンドウを画面にはみ出させたレイアウトにしたい ことはあるでしょう。しかし、iPhoneXでおかしくならないようにするならば、 かなり画面の外側まで枠を伸ばさないといけなくなり、素材の容量も大きくなるし、 見栄えも悪くなります。 そこで、東京プリズンでは「あまり横長であれば端が出てくる」というデザインにしています。

f:id:hirasho0:20190125212423j:plainf:id:hirasho0:20190125212430j:plain

プレビュー

エディタ拡張で2つのボタンを置いており、これを押すと上記設定をレイアウトに反映します。 「ここだけApply」では「このRectTransformだけ」をいじり、 「再帰的Apply」では、この下流にある全てのRectTransformScalerの処理を行います。

実は、東京プリズンに入っているバージョンでは、OnValidateから設定反映処理を呼んでおり、 設定をいじれば即時反映されました。 しかし、これをやると予期しないタイミングでシーンやプレハブが変更されてしまいます。 gameViewの解像度を変えた時に、開いていたシーン内のRectTransformScalerが動作して RectTransformのanchorMin,anchorMax,pivot,scale,posX,posYを書き変えてしまい、 gitでコミットすると変な差分が出てしまうのです。 作業する人によって異なる解像度でgameViewを設定しているために、これが頻繁に起こります。

なので、今回サンプル化するにあたって手動にしておきました。 もちろん、実行時にはMonoBehaviour.Startで反映処理が行われますので、実行時には自動でレイアウトが行われます。

初期化

実行時には初期化が必要です。 safeAreaの寸法を教えてやらねばなりません。

製品では解像度やマージンを持っているクラスが別にいて、 RectTransformScalerはそれを見に行っていましたが、 サンプル化に伴って変更しました。 マージンはRectTransformScalerのstatic変数としています。

以下はサンプルシーン内に置いてある初期化用のMonoBehaviourです。 Main Cameraにつけてあります。

public class Sample : MonoBehaviour
{
    // 中略
    public RectTransform canvasRectTransform;
    public float marginLeft;
    public float marginRight;
    public float marginTop;
    public float marginBottom;

    void Start()
    {
        Apply();
    }

    void Apply()
    {
        var canvasSize = canvasRectTransform.sizeDelta;

        Kayac.RectTransformScaler.SetNormalizedMargin(
            marginLeft / canvasSize.x,
            marginRight / canvasSize.x,
            marginTop / canvasSize.y,
            marginBottom / canvasSize.y);
        // 中略
#if UNITY_EDITOR
        Kayac.RectTransformScaler.ApplyRecursive(canvasRectTransform);
#endif
    }
}

Start()Apply()が呼ばれ、 inspectorで設定されているマージン情報をRectTransformScaler.SetNormalizedMargin() に渡しています。さらに、エディタではApplyRecursive()を呼んで、 canvasの下の全てのRectTransformScalerについて設定反映を行っています。

実際に製品で使う時には、まだ絵が出る前に読まれるシーンが何かしらあって、 そこでSetNormalizedMargin()が呼ばれることを想定しています。 絵が出るシーンはその後で読み込まれる、ということであれば、 RectTransformScalerのStart()で正しくレイアウトされるので、 特に何もする必要はありません。

設計について

今回の仕組みは、以下のような状況で生まれました。

  • 急にiPhoneXが出てきて、16:9より横長なものに対応しないといけなくなった。
  • すでに大量にモノを作ってしまっていたので、アーティストやUnity側プログラマの作業をできるだけ減らしたかった。
  • 一つのCanvasの下に設定(右寄せか左寄せか、画面に収めるか全域塗るか)が異なる複数の部分があった。

実は、Canvasにつける似たようなコンポーネントが まーだー氏によって先に作られました。 多くの場所は今でもそれを使っていますし、今回のものの実装もおおよそそこから持ってきています(感謝!)。 ただ、当時は今回で言う所のScaleModeの設定だけあれば足りたので、 左寄せ右寄せ等の設定機能はありませんでした。 また、Canvasごとなので、Canvasの下に設定が異なる部分が複数ある場合に対応できませんでした。 そういうわけで、実験的にRectTransformごとにつけられる別のものを用意したわけです。

設定項目について

元になったCanvasにつけるコンポーネントでは、選択肢を「幅に合わせてスケール」のような挙動でなく 「背景」のような用途で書いていました。 「背景」「全画面」「safeAreaに収める」の3種です。 例えば「背景」はsafeAreaを無視して塗り残しがないようにする設定で、 今回のもので言う「ScaleMode=Max、useMargin=true」に当たります。 用途で書く方が普通はわかりやすいでしょう。

エディタ上の設定UIを誰に向けて作るかは大きな問題です。 やらないことができる必要はないのですから、 やることを網羅した範囲で最も小さく簡単なUIが最高、ということになります。 しかし、必要な機能が増えたことと「どうせ私しか使わない」と割り切ったこともあって、 今回は挙動をそのまま名前にした設定項目にしています。 もし多数の人に使ってもらうのであれば、 「背景」「左寄せsafeArea内」「幅合わせsafeArea内」 のように用途に基いた選択肢を増やしていたかもしれません。

なお、実を言えば、もっと汎用化することもできます。 左寄せと中央寄せの間には無限の段階があり、 Left,Center,Right、というのを一つのスライダーにするとより汎用性が上がります。 この場合、

  • 0なら左端、0.5なら中央、1なら右端
  • 間は補間。例えば0.1なら、画面の左端から20%内側に行った所に合わせる

というような作りが良いでしょうか。 しかし、とりあえず今やらないことをできるようにしても仕方ないので、 今は敢えて3つの選択肢に限定しています。

使用の実際

東京プリズンにおいて想定しているアスペクト比は、 4:3から2.1:1までです。つまり、正方形に近い方の極限がiPadで、横長の極限がiPhoneX、ということになります。 この範囲を越えた場合、表示が崩れます。 自動で調整するにしても限度があり、 この文書の上の方に挙げた画面例でも、さすがに縦長には対応できていません。

また、今回は2Dの配置しか扱っていませんが、3Dが混ざるとまた厄介な点があります。

f:id:hirasho0:20190125212420j:plainf:id:hirasho0:20190125212427j:plain

3Dのカメラによる描画の上に2D描画をしています。 2D要素は今回の仕掛けによって左下寄せ、右下寄せ、右中央寄せ、 の3要素に分割され、赤く示したsafeArea対応もできています。 しかし、3D描画は分割できないので調整のしようがありません。 この製品では3D部は画面の高さで合わせていますので、 iPadでは幅が狭く感じられます。しかし、幅で合わせると、 iPadで手前と奥行方向が見えすぎてしまって、背景を余計に作り込まねばならなくなる という問題がありました。無理に配置をいじるよりも、余ったところに黒い帯を貼って隠す方が 処理負荷的にも、使い勝手的にも、そして見栄えとしても良いかもしれません。 これは2Dしかない場合でも同じことが言えます。

結局のところ、自動で調整されるとはいえ、実機で試してみる手間はどうしても必要です。 例えば「画面内に入ることを保証する」と言っても、 それが押しやすいサイズかどうかは見てみないとわかりません。 操作しやすい配置かどうかも、結局は触ってみないとわかりません。 iPhoneXという、「たくさん所有者がいそうなのに変わった解像度」 の端末が現れたために、そのあたりのテストと調整には結構な手間がかかりました。

例えば、上の画面写真の左下の顔アイコン二つが画面下端から浮いているのは、 iOS11以降の「画面端からドラッグ(スワイプ)するとOS機能の画面が出てくる」 という仕様によって誤操作が相次いだためです。最初は下端にありました。 このように、アスペクト比やsafeArea以外にも罠があるわけです。

実装について

実装についてはコード をご覧頂く方が早いかと思いますので、詳細には述べません。エディタ拡張を抜けば170行ほどで、 座標計算の中央部 は25行程度です。

簡単に概要を示せば、「親RectTransformのwidth,heightから、static変数に設定されたマージンを削り、自分のwidth,heightとScaleModeから適切な拡大率を算出し、上下左右の寄せをanchoredPositionで設定する」 となります。親のRectTransformが100x100だったりするとおかしなことになりますので、 基本的には全画面のCanvasや、全画面に設定したRectTransformの下に配置することを想定しています。 しかし、敢えてそうでないRectTransformの下に置くことにも、何かしらの用途があるかもしれません。

なお、「選択したgameObjectの下流にあるRectTransformScaler全部に反映処理をする」 という機能をGameObjectメニューに足してあります。 このサンプルではSampleクラスに足したボタンがその機能を持っていますが、 実際には様々なシーンで用いるので、このように右クリックメニューに加えておきました。 ただ、作っておいて言うのもなんですが、なくてもあまり困らない気はします。

f:id:hirasho0:20190125212433p:plain

まとめ

多機種で動くアプリケーションを作る際には、大抵解像度やアスペクト比のバラつきが 問題になります。iPhoneXのおかげでさらにそれがややこしくなっており、 ある程度の手間は見込んでおかないと後でひどい目に遭います(遭いました)。

最適なレイアウトのためにはデザイナーや企画との協力が欠かせませんが、 とりあえず「多少の多様性」に関してはプログラマ側で ちょっとした仕掛けを作ることで吸収はできます。 そういった仕掛けの一例として、このようなものを紹介いたしました。

ご意見、ご感想等いただけると幸いです。 また、他の製品での事例も公開していただけると 私を含めて多数の人が幸せになると思いますので、他社さん検討よろしくおねがいいたします。

補間関数で作るアニメーション

f:id:hirasho0:20190125210617g:plain

プログラムで動きを作ることは結構あります。

操作せずに観賞するアニメーションであれば、 アーティストが作る方がいい出来になると思うのですが、

  • 操作によって介入があるアニメーション
  • 場所や大きさ、速度、数などがゲームの状況に応じて変化するアニメーション
  • アーティストが作るまでもないアニメーション

のようなものはプログラマがやることが多いかと思います。 この記事では、技術部平山 がコードでアニメーションを作る際の基礎知識について書いてみます。

なお、少々数学臭が強くなりました。平山は正直数学が苦手でして、 かなり苦労して書いております。間違いがありましたら連絡頂ければすぐに修正いたします。 さらに、「時間を取って平山に数学を教えてやろう」 という方がいらっしゃいましたら、泣いて喜びますので、よろしくおねがいいたします。

とりあえず見てみてください

動きの話は文章で説明しても辛いので、まずは見ていただこうと思います。 実行可能なサンプルをweb上に置いておきました。 こちらを実行しながら見ていただけると良いかと思います。

「Linear」から始まるボタンの行に関数の名前が並んでいるので、 適当に選んで押してください。下の行にあるrewindボタンを押すと最初の位置から再生されます。 最初の位置と最後の位置が決まっていてその間をつなぐのですが、 つなぎ方が関数によって変わります。 たくさんキーフレームを置いていい感じのアニメを作る、 という路線はアーティストにお任せし、ここでは「計算で間をつなぐ」ことだけを考えています。

また、右下のグラフは拡大率を毎フレームプロットしたもので、 動きの性質がなんとなくわかります。

コードをいじりながら見たい方、ローカルで動かしたい方は、 githubにサンプルコード を置いておいたので、そちらをご利用ください。

では、関数それぞれを見ていきましょう。

線形補間(Linearボタン)

f:id:hirasho0:20190125210458g:plain

最初の値と最後の値を直線的につなぎます。 例えば角度なら、最初が0度で2秒後に180度なのであれば、1秒後は90度です。

実装は簡単です。まず、今の時刻が、最初の値を取る時刻と、最後の値を取る時刻の 間のどのへんにあるかを求めます。

例えば、時刻1で90度、時刻3で180度、今の時刻は2、 ということであれば、1と3の真ん中なので0.5とします。 0.5は50%の意味で、「時刻1の値と、時刻3の値を50%づつ混ぜる」ということになります。

では、この0.5はどう計算するか。こうです (サンプル内の相当するコード)。

t = (now - t0) / (t1 - t0);

t0は最初の時刻(ここでは1)で、t1は最後の時刻(ここでは3)です。 今の時刻である2から、1を引きます。これを、二つの時刻の差で割ります。 そうすれば0から1になるわけです。 あとは、このtがブレンド率になっているので、ブレンド計算を行います。

p = (p0 * (1f - t)) + (p1 * t);

最初の値をp0、最後の値をp1としました。 tが0なら最初の値であるp0に、tが1なら最後の値であるp1になってほしいので、 p0には1-tを掛け、p1にはtを掛け、それらを足します。

なお、この式だと演算子が、掛ける、引く、足す、掛ける、で4つあり、 ちょっと無駄が多いので、大抵は以下のように変形します (サンプル内の相当するコード)。

p = ((p1 - p0) * t) + p0;

p1からp0を引いたものに0から1であるtを掛け、それにv0を足すわけです。 UnityEngine.Mathf.Lerpの中身はこれです。 ここで、a=(p1-p0)b=p0と置くと、p=at+bという一次関数であることがわかります。

さて、こいつの動きはいかがでしょうか。 素直と言えば素直ですが、面白味はありません。 私は0.2秒以内で終わるものでない限り、これで動きをつけることはあまりしません。 ちょっと手抜き感があります。少なくとも、生き物はあまりこういう動きをしません。

二次関数補間、初速ゼロ(QuadraticBeginV0ボタン)

では、線形補間以外にどんな形が考えられるでしょうか。 「線形」というのはp=at+bのような「一次関数」のことですから、 次は二次関数にでもしてみましょう。p=at^2+bt+cです。

式の形を決める値がa,b,cで3つあります。 最初の値と最後の値で式が2つできますが、 未知数は3個あるので式が足りません。 何かしら式を追加して連立方程式を解くことになります。

連立方程式

「連立方程式なんて言葉は何年ぶりかな」という方も いらっしゃるかもしれないので、 先程の一次関数を題材に連立方程式を解いてみましょう。

今関数の形をp=at+bと置きます。aとbを知りたいので、 未知数は2つです。 最初の時刻0と最後の時刻1で取るべき条件から、

p0 = a*0 + b
p1 = a*1 + b

と式が2つできます。1つ目から即座にb=p0がわかり、 これを2つ目に代入すればa=p1-p0が出てきます。 つまりp=(p1-p0)t+p0が求まります。

では二次関数も同じやり方でやりましょう。 まず、式2つは最初の値と最後の値から決まります。

p0 = a*0*0 + b*0 + c
p1 = a*1*1 + b*1 + c

式が足りないので、何らかの条件を足すのですが、 ここでは「開始時の速度(初速)が0」としてみましょう。

速度というのは、関数の微分のことです。 p=at^2+bt+cを微分すればp'=2at+bになります。 これが、時刻0で0なので、

0 = 2*a*0 + b

となり、式が3つ決まりました。0や1を消して整理して並べると、

p0 = c
p1 = a + b + c
0 = b

となります。bとcはすでに求まっていますね。 1,3つ目を2つめに代入すればaが求まり、

a = p1 - p0
b = 0
c = p0

となります。関数形はp=(p1-p0)t^2+p0となりました。 計算するコードは、

float a = p1 - p0;
return (a * t * t) + p0;

となります (サンプル内の相当するコード)。

動き

さて、こいつの動きはどうでしょうか。サンプルのQuadraticBeginV0です。

f:id:hirasho0:20190125210517g:plain

速度ゼロから徐々に加速してくる感じはいいのですが、 唐突に止まるあたりに少々不自然さを感じます。 現実世界で急に止まるのは衝突した時で、 若干行き過ぎたり、変形したり、反発したりするものです。 それがないのが物足りないのでしょうか。 終わった所でエフェクトや音による「衝突感」があれば いいのかもしれません。

式について補足

できた関数はp=(p1-p0)t^2+p0ですが、これはt^2sと書くと、 p=(p1-p0)s+p0となり形が一次関数と同じになります。 ということは、tを二乗してから一次関数に放り込めば同じ結果になるということです。 一次関数による線形補間で書いた後で「加速が欲しいな」と思ったら、

t *= t;

と一行書くだけで二次関数になり、加速感が出ます。

二次関数補間、終速ゼロ(QuadraticEndV0ボタン)

初速ゼロがあるなら、終わりの速度がゼロ、というのも考えられます。

f:id:hirasho0:20190125210525g:plain

動き始めが鋭いので、ボタンを押した時の反応などには良さそうです。

そういえば、「人間は、原因から結果までの間が0.14秒以上空くと、因果関係を感じられなくなる」 とどこかで読みました。本当かどうかは知りませんが、 私はそれを聞いてからというもの、押したら0.14秒以内に十分大きな 反応が出るように作ることを心がけています。 例えばポップアップなどは0.14秒以内に十分広がるようにしています (サンプルの「popup motion」というトグルを押すと、アニメーションが ポップアップ風に変わるようにしておきました)。

求める

では関数の形を定めましょう。

2つの時刻での値から式が2つ、というところは同じで、 速度、つまり微分の条件で式を1つ作る所も同じです。 ただ、速度条件が「時刻1」についてのものになります。

0 = 2*a*1 + b

ちょっと変形するとb=-2aとなり、b=0よりは解くのが面倒ですね。 解くと、

a = p0 - p1
b = -2 * (p0 - p1)
c = p0

となります。計算するコードは素直に書けば、

var a = p0 - p1;
var b = -2f * a;
return (a * t * t) + (b * t) + p0;

ですが、こうやって書くと、かけ算が多くて遅いし、誤差も大きいしで、いいことがありません。 p=at^2+bt+cを、できるだけtでくくって、p=(at+b)t+cと変形し、 これをコードにします。

var a = p0 - p1;
var b = -2f * a;
var ret = a;
ret *= t;
ret += b;
ret *= t;
ret += p0;
return ret;

atを計算し、bを足し、それにtを掛け、cを足します (サンプル内の相当するコード)。 見やすいように演算一個ごとに1行使いましたが、return (((a*t) + b) * t) + p0;とくっつけてもいいでしょう。 こうやって多項式を計算する方法をホーナー(horner)法 と言います。

三次関数補間、初速ゼロ、終速ゼロ(CubicBoundaryV0ボタン)

二次関数の場合、初速か終速のどちらか与えられません。 未知数が3個なので、式が3つしか立てられないからです。 もし、始まりも終わりも速度ゼロにしたければ、 関数をもっと複雑にして未知数を増やす必要があります。 そこで、三次関数にしてみましょう。

求める

関数形はp=at^3+bt^2+ct+dとします。 まず、時刻0と1での値がp0,p1なので式が2つできます。

p0 = a*0*0*0 + b*0*0 + c*0 + d = d
p1 = a*1*1*1 + b*1*1 + c*1 + d = a + b + c + d

いきなりdは解けました。次に、速度の条件で式を2つ立てます。 微分はp'=3at^2+2bt+cなので、

0 = 3a*0*0 + 2b*0 + c = c
0 = 3a*1*1 + 2b*1 + c = 3a + 2b + c

となります。cも解けました。未知数が2個に減ったので式を整理してみましょう。

p1 = a + b + p0
0 = 3a + 2b

1つ目をb=...に直して2つ目に代入するのが楽ですかね。解くと、

a = 2(p0-p1)
b = 3(p1-p0)
c = 0
d = p0

となり、計算するコードは、hornor法で、

float a = 2f * (p0 - p1);
float b = 3f * (p1 - p0);
var ret = a;
ret *= t;
ret += b;
ret *= t;
// c=0なので何も足さない
ret *= t;
ret += p0;
return ret;

となります (サンプル内の相当するコード)。

動き

動きはこんな感じです。

f:id:hirasho0:20190125210426g:plain

始まりから加速して、減速して終わります。滑らかさが欲しい時には便利そうです。

操作と相性がいい補間が欲しい

さて、4つほど見てきましたが、この4つには共通点があります。 「時刻が決まると値がビシッと決まる」ということです。 必要な変数が時刻だけなので扱いが楽な一方、実は困ることもあります。

ポップアップ開けてる最中に閉じたい問題

今、ボタンを押すとポップアップが拡大しながら出てくるとしましょう。 関数型は何でもいいのですが、「終わり速度0の二次関数」 あたりが良いでしょうか。 さて、そのポップアップが完全に拡大し切る前に、 再度ボタンが押されたとします。 こういう時は、おそらくキャンセルでしょう。 ポップアップを閉じます。

いきなりパッと消してもいいと言えばいいのですが、 もし閉じる時にもアニメーションをつけたい、ということになると少々面倒です。

まず、同じ関数を逆に辿って消すなら簡単なのですが、 操作に対する反応は鋭い方がいいと思います。 終わり速度0の二次関数は減速で、逆に辿ると加速になりますから、 それでは反応が鈍いのです。

そこで、「開ける時と閉じる時で別の関数を用意する」 ということがやりたくなります。 開ける時も閉じる時も「終わり速度0の二次関数」を用意するのですが、 開ける時は「スケール0から1へ」、閉じる時は「スケール1から0へ」となり、 値が違いますから、これは違う関数です。

そして、例えばt=0.8で閉じるボタンが押された場合、 閉じる関数のそれに相当するtを計算して、そこからアニメーションを開始します。

具体例で説明しましょう。

開ける時には、t=0でスケール0、t=1でスケール1となる、終わり速度0の二次関数を用意します。 関数はp=-t^2+2tです。 閉じる時には、t=0でスケール1、t=1でスケール0となる、終わり速度0の二次関数を用意します。 関数はp=1-t^2です。

今ボタンを押して、ポップアップを出しました。アニメーションが進行し、 t=0.5でもう一度ボタンを押し、そこから閉じるとします。 t=0.5の時にはp=-(0.5*0.5)+(2*0.5)=0.75です。75%の拡大率になっています。 これを閉じる方の式であるp=1-t^2にあてはめます。

p=1-t^2=0.75なので、t=の形に解いて、t=sqrt(0.25)で、t=0.5t=-0.5の 二つの解が出てきます。今の場合マイナスの解は無視できますから、プラスを取りましょう。 t=0.5から閉じる方のアニメーションを再生するわけです。

さて、今は関数型が単純だからまだ良かったのですが、もうちょっと複雑になると、 この作業がひどく面倒くさくなります。両方とも三次だったらどうなるか考えてみてください。 p=at^3+bt^2+ct+dの関数で、今pが0.8だったとします。相当するtはいくつでしょうか? これは3次方程式を解く問題で、結構面倒ですし、しかも答えが一つとは限りません。

というわけで、「途中で何か横槍が入って、別の関数につなぎたい」 というような場合には、もっと楽な方法が欲しくなるのです。

逐次計算で表現する

途中で横槍が入ることを前提とするならば、 「今の値」を持っておいて、そこに毎フレーム修正を加えて動かしていくのが簡単でしょう。

例えばポップアップの例で言えば、 まずスケール0から始めて、毎フレーム0.1を足していけば、10フレームで1になります。 もし途中でもう一度押されたら、今度は毎フレーム0.1を引いていけば、そのうち0になります。 面倒な計算はいりません。

p += 0.1 * Time.deltaTime;

実際には上のように、Time.deltaTimeを0.1に掛けてから足します。 でないと、フレームレートが高い機械ほど速く動いてしまうからです。

実に簡単なのですが、残念ながらこれは線形補間と同じ動きで、あんまり使い所がありません。 他に素敵な計算はないでしょうか。

指数関数(Exponentialボタン)

毎フレーム一定量を加算していく、という以外に、目的値に近づくための計算としては どんなものがあるでしょう。

いろいろありそうですが、足し算があるならかけ算もありそうです。 「毎フレーム目的値との差が0.9倍になっていく」というような計算はどうでしょう。

p += (p1 - p) * 0.1f;

p1が目的値、pが今の位置です。(p1-p)は目的値との差ですね。これに0.1を掛けたものをpに足すと、 目的値までの距離が90%になります。あと500kmだったものが450kmになるわけです。 そして次のフレームでもう一度計算すると、450kmの0.1倍である45km進みますから、 残り距離は405kmになります。こうしてどんどん近づいていくわけです。

実際に使う時には先程と同じく、Time.deltaTimeを用いて、 フレームレートに依存しないようにします。

p += (p1 - p) * 0.1f * Time.deltaTime;

deltaTimeを掛けるならば0.1のままでは遅すぎるので、 もっと大きくすることになるでしょう。

動き

では、この動きを見てみます。サンプルでExponentialを選んでください。

f:id:hirasho0:20190125210435g:plain

終わり速度0の二次関数に似ていますが、終わり方はもっと緩やかです。 また、逐次計算にしてあるので、途中でswitchGoalボタンを押して目的地を変えた場合、 そこから即座に反応してつなぎます。

ただ、終わりが緩やかすぎる気はします。 実はこの関数は、永遠に目的地に辿りつかないのです。 先程の計算を思い出してください。 例えば500kmから近づいていく例では、10回計算してもまだ174km残っています。 40回計算すればあと7.4kmまで近づきますが、ゼロにはなりません。 しばらく待てば止まるようには見えますが、それは 「遅すぎて止まってるように見えているだけ」で、 実はまだ動いているのです。

そして、それにかかる時間を簡単に指定することもできません。 画面上端中央あたりにExpCoeffとあり、その右にあるスライダーでいじれます。 これを先程の式の0.1の代わりに使います。 1だと、だいたい1秒後に63%(1-1/e。eは自然対数の底)くらいの所まで行きます。 2だと、だいたい1秒後に86%(1-1/e^2)くらいの所まで行きます。 「99%に到達するまでt秒かかるようなパラメータを計算する」というようなことはできますが、 90%がいいのか99%がいいのかは用途や趣味によりますので、結構面倒なところです。 私はとりあえず4を入れて様子を見ることが多いですが、根拠はありません。 二次関数や三次関数ならばt=1になれば最終値になるわけで、 これは結構面倒です。

理屈

さて、実はこれが指数関数になっているわけですが、それはどういうことでしょうか。 理屈に興味がない方は飛ばしていただいてかまいません。

指数関数補間

まず、普通に指数関数で補間することを考えましょう。

指数関数で2点を補間する場合、関数の形はp=a*exp(-k*t)+bという具合が便利です。 一次関数のtをexp(-k*t)で置き替えた感じです。 そして、expの中は負になるようにします。そうすれば、t=0でexpが1、tが無限大でexpが0になります。

p0 = a*1 + b
p1 = a*0 + b

から、b=p1a=p0-p1と定まります。つまり関数形はp=(p0-p1)*exp(-k*t)+p1 です。kは好きに決めていい値です。動きの調整に使えます。

さて、先程書いた処理は、

p += (p1 - p) * 0.1f * Time.deltaTime;

というものでした。これを毎フレーム繰り返した結果が、p=(p0-p1)*exp(-k*t)+p1 という関数と同じ形をしているということです。

積分

何度も何度も何かを足して値を作っていく、というのは、つまるところ積分です。

先程の0.1づつ足していく話で考えましょう。0.1づつ足していってできる関数は、 一次関数p=at+bと同じでした。つまり、0.1を積分していくと一次関数になるということです。 逆に言えば、一次関数を微分すれば定数になるということで、その通りです。p'=aですね。 このaが0.1だったわけです。

つまり、ある関数を逐次計算に変えたければ、それを微分して毎フレーム足せばいいのです。 微分して積分すれば戻りますよね。

指数関数を微分してみる

では、指数関数を微分してみましょう。 まずexpの微分はexpですが、中のtに何か掛かってる場合は外に出して掛け算します。 exp(-k*t)-k*exp(-k*t)になります。結果、

-k*(p0-p1)*exp(-k*t)

が微分です。あとは今の用途に合わせて変数の値を決めます。 常に「今から補間を開始する」ことにしてしまえば、 p0は今の位置pで置き替えられ、tはいつも0ということになります。 expの中身が0になるので、expは1になり、消えます。 ついでに先頭のマイナスを(p-p1)の中に入れて引き算をひっくり返し、 一番簡単なオイラー法での積分を行えば、

p += (p1 - p) * k * Time.deltaTime;

となります。オイラー法では、微分にTime.deltaTimeを掛けて今の値に足します。 kに0.1を入れれば、元の式と同じになります。

使用上の注意

このまま使う場合、たまに危険なことが起きます。 k * Time.deltaTimeが1より大きくなる場合を想像してください。 目的値より先に行ってしまいます。 さらに、2を超えると破滅的なことが起こります。

今「目的値まで100km」にいるとしましょう。今k * Time.deltaTimeが 3あったとすると、次のフレームの計算で「目的値を遥かに超えて逆側に突き抜け、距離が200kmに増える」 という恐ろしい状態になります。さらに次のフレームでは距離が400kmに増え、 どんどん遠ざかっていくのです。いずれはfloatの限界値を超えてinfinityに至り、 Unityの場合、これをTransformに入れたり頂点座標に入れたりすると、Unityごと落ちます(mac版のエディタだけ?)。 なので、この式を使う場合には、kの値には制限をかけておく必要がありますし、 Unityの設定Maximum Allowed Timestepをあまり大きすぎない値に設定しておくのも良いかと思います。 0.1くらいでいかがでしょうか。

それにしても、この問題ははどこから来たのでしょう。何か変な近似しましたっけ?

実は「オイラー法での積分を行えば」というところが問題です。 厳密に積分するのであれば、t=0からt=Time.deltaTime(長いので以下dt)までの定積分を求めて、 それを加算せねばなりません。幸い、今の場合はそれが簡単にできるので、 やってみましょう。積分した後の関数はわかっているので簡単です。 不定積分(p-p1)*exp(-k*t)に、t=dt を入れると(p-p1)*exp(-k*dt)になり、t=0を入れると(p-p1)になりますから、

(p - p1) * (exp(-k * dt) - 1)

が定積分になります。(p-p1)の向きを先程の式に合わせてコードにすると、

p += (p1 - p) * (1f - Mathf.Exp(-k * Time.deltaTime));

となります。いらない心配をしたくなければ、こちらの実装をしておく方が安心ではないでしょうか。

用途

指数補間は相当便利で、私は操作で横槍が入り得るものにはほとんど何でも使っています。 時刻の代わりに現在値を覚えておき、 単に目的値とパラメータ(上のcoefficient)を差し換えれば、勝手に補間されます。 どうせ現在値はRectTransformに入っていますので、 そこから値を取るのが面倒でなければ、現在値を取っておく必要もありません。 ボタン操作等々でバンバン目的値を差し換えても、それなりに補間されます。

ただし、パラメータをどれくらいにするかを決めるのが面倒なのと、 じわじわ動き続けて気持ち悪いのが難点です。 例えばパラメータが1だと、99%の位置に到達するまで4秒以上かかります。 にも関わらず、1秒足らずで50%の所を過ぎてしまうわけで、少々終わりが遅すぎです。 これが気に入らないのであれば、多少面倒でも二次関数を使う方が良いかもしれません。

なお、expで目的値に限りなく近づいていくような現象は自然界にはたくさんあります。 お湯が冷めていく時、その冷め方はexpです。 また、霧の日に対象物がどれくらい見えなくなるかの具合は、 距離を変数としたexpで決まります。 「変数が1増える度にN%に減る」的な現象はexpになります。 操作による横槍が入らない場合であっても、使える局面は多いかと思います。

バネダンパ(SpringDamperボタン)

もう一個面白い奴があるので紹介しましょう。 物理シミュレーションを応用するやり方です。

今、pにある物と、p1にある地面に刺さった杭を、バネで結ぶと想像してください。 このバネは元々すごく短くて、無理矢理伸ばして結んでいます。 手を離すと、pにあるものはp1に向かって引っぱられていきます。 そのうちバネの本来の長さに縮んで、そこで止まるでしょう。 pはだいたいp1の近くまで行くはずです。

これを使います。

物理シミュレーションと言っても難しくありません。 まず、加速度を計算する方法を用意します。 今の例ではバネが伸ばされていると、縮む方向に加速度をかけます。 あとは、毎フレーム加速度を積分、つまり加算して速度を計算します。 そして、その速度を積分、つまり加算して位置を計算します。

a = CalcAccel(); // 加速度を計算(aはaccelのa)
v += a * dt; // vはvelocityのv
p += v * dt; // pはpositionのp

毎度のことですが、dt(=Time.deltaTime)を掛けてから足すのは、フレームレートに 依存しないで同じような結果を得るためです。

あとは加速度の計算ですが、「目的値から遠いほど強くひっぱられる」 なので、こんな感じではいかがでしょうか。

a = (p1 - p) * spring; // p1は目的値

p1-pが大きいほどaは大きくなります。springは調整用の数で、 好きに設定できます。

とりあえず動きを見てみる

これで作った動きが、これです。

f:id:hirasho0:20190125210537g:plain

止まりません。

考えてみれば、現実世界でバネが止まるのは、摩擦があるからです。 摩擦がなければ、行き過ぎた上に同じ場所まで戻ってきて、永遠に振動します。 実は、加速度を先程の式で定めて積分した場合、 位置はsin関数になります。sinなので当然いつまでも振動するわけです。

というわけで、摩擦に相当するものを入れましょう。先に動きをご覧ください。

f:id:hirasho0:20190125210549g:plain

動きが落ちつきました。画面右上のdamperとあるスライダーを0でなくしたのが 先程との違いです。 「減衰させるもの」という意味のダンパーという言葉を使い、 この仕組みで制御することをmass-spring-damper systemと呼んだりします。

なお、damperの値を弱くすると、「ちょっと行き過ぎつつ戻って落ちつく」 という挙動にもできます。

f:id:hirasho0:20190125210617g:plain

現実世界のものは急停止できず、余裕をもって減速するか、行き過ぎて少し戻るかになりますが、 それと同じです。damperを強くすれば余裕をもって減速し、弱くすれば行きすぎてから戻るわけです。

コード

計算関数は以下のようになっています (サンプル内の相当するコード)。

float a = ((p1 - p) * spring) - (v * damper);
v += a * dt;
p += v * dt;

aが加速度で、目的値と現在値の差にspringを乗じて足して加速し、 速度にdamperを掛けた分を引くことで減速させます。

特徴

このバネダンパ方式は、指数関数よりも始めが滑らかになる点が特徴的です。 指数関数は目的値を切り換えた瞬間が最大速度ですが、 こちらの場合は加速に多少時間がかかります。 そして、振動させられるのも他にない特徴です。

正直に言えば、指数関数ほど使い勝手は良くありません。 パラメータが二つあって調整が面倒ですし、 指数補間以上に落ちつくのにどれくらいかかるのか読めません。 指数補間なら「1秒後に63%」のようなことが言えましたが、 こちらは「パラメータをいくつにすればどうなるのか」がわかりにくいのです。

しかし、振動が欲しい時には合いますし、 反応が鈍い方がいい用途もあります。 さらに、毎フレーム目標値を変える場合には、落ちつくまでの時間が 計算しにくいことはあまり問題ではありません。 こういった特徴は例えばカメラ制御には合います。 ゲーム中の操作に追随して目的値を次から次へと変える必要があり、 反応は鈍い方がリアルで、かつ、少し行き過ぎて戻る振動がまたリアルさを感じさせます。

もう一つ、この方式は途中で突発的な動きを混ぜるのが楽です。 例えば、キャラAを追いかけるキャラBに対して、 誰かがボールをぶつけてはじき飛ばした、というようなケースです。 サンプルプログラムでSpringDamperを選んでいると、 下段にpulseというボタンが現れます。これを押すと、 速度に直接値を加えることで跳ね飛ばします。

f:id:hirasho0:20190125210508g:plain

いつどのように邪魔されても、それなりにそれっぽい動きをしながら目的値に向かいます。 これは速度を持っているからこそできることです。

使用上の注意

指数関数の時に、「オイラー法で積分したら宇宙の果てに飛んでいってUnityが死んだ」 というお話をしました。 今回も同じことが起きます。 もう一度コードをご覧ください。

float a = ((p1 - p) * spring) - (v * damper);
v += a * dt;
p += v * dt;

springやdamper、そしてdtが大きくなると、宇宙の果てに飛ぶことになります。

damperの調整範囲

まずはdamperから考えます。springを0としてみましょう。すると、

v += -v * damper * dt;
p += v * dt;

となります。damper * dtが1より大きいと、速度が反転していまいます。 指数関数の時に目標値を追い抜いたのと同じですね。2を超えると、 速度がどんどん大きくなって、いずれUnityを殺します。

というわけで、damperには制限をかける必要があります。 damper * dtが1を超えないように、調整範囲を制限しましょう。 つまり、

maxDamper = (1f / Time.maximumDeltaTime);

ということです。

springの調整範囲

次にspringの調整範囲を考えます。damperを0としてましょう。

v += (p1 - p) * spring * dt;
p += v * dt;

このままだとよくわからないので、「つまり位置はどうなるわけ?」というのを考えるために、 下の行のvを、上の行の計算で置き替えます。

p += (v * dt) + ((p1 - p) * spring * dt * dt);

vがどうなっているかはわかりませんが、とりあえずspring * dt * dt が1を超えると、pがp1を追い越しそうです。 spring * dt * dt < 1ということは、つまり、

var maxDt = Time.maximumDeltaTime;
maxSpring = (1f / (maxDt * maxDt));

としておくのが無難だろう、ということになります。 最大でTime.deltaTimeが0.1になるのであれば、 springの最大値は100です。それ以上をつっこむと、 行きすぎたり、宇宙の果てに飛んだりする恐れがあります。

なお、指数関数の時のように、オイラー法で近似せずに、きちんと定積分を計算することも可能だと思います。 Wikipediaや、 この文書 が助けになるでしょう。でも相当面倒くさいので私はやっていません。

なお以下は、これを使って試しに作ってみたボタンポップアップ です。バネダンパなので、若干行きすぎて戻る挙動が表現でき、途中で違う動きにつなぐことも容易です。ただし調整には慣れがいります。

f:id:hirasho0:20190125210413g:plain

関数は他にもある

さていろいろ見てきましたが、補間に使える関数は他にもたくさんあります。パッと浮かぶだけでも、

  • 4次以上の多項式
  • 三角関数
  • 逆数
  • 平方根、立方根
  • 対数

というくらいはありますね。条件が満たされればこれらを使って補間をするのも 良いと思います。 ただ、私自身はあまり用途が思い浮かびません。 単純に振動させたい時に三角関数を使うことがある、という程度ですが、 バネダンパでいい気もします。途中で何かぶつけて乱したりもできますし。

終わりに

数学の臭いが少し強くなりましたが、「動き」=「数値の変化」=「関数」 ですから、どうしてもそうなってしまうのです(数学は苦手なので書くのは苦労しました)。

しかしこの話は、

  • カメラ制御
  • 3D回転の補間(クォータニオンの利用)
  • AIで動くキャラクターの動き制御

といった話題につながる基礎で、奥深く、しかもゲームの遊び勝手に結構関わってくる分野です。 私もこれから修行を積んで、そういった分野で成果が出せるように がんばろうと思っております。