モバイル向け開発は機械が様々です。 解像度は様々、アスペクト比も様々です。 最近は一部画面が欠けていて、ある範囲(SafeArea)に収めないと映ること保証しないよ、 という機械まで出てきました。
さて、これにどう対応するのが楽か?
技術部平山が、 弊社東京プリズンで行った手法をベースに、一つの例を示します。
こんなの
ここではこんな例を見てみます。
この画面には5つの要素があります。
- 緑の楕円ウィンドウ。上寄せでsafeArea内にボタンを置きたい。画面幅に対する比率を一定にしたい。
- 青の楕円ウィンドウ。左寄せでsafeArea内にボタンを置きたい。画面高さに対する比率を一定にしたい。
- 赤の楕円ウィンドウ。safeArea内の中央に置きたい。どんな解像度でも画面内に収めたい。
- 右のキャラ絵。safeArea無視して右下寄せで置きたい。どんな解像度でも画面内に収めたい。
- 背景。画面全域を埋めたい。
この実解像度(Screen.width及びScreen.height)は1280x720の16:9です。 よくある解像度ですね。
さて、これを、4:3の機械(iPadとか)に持っていきます。 safeArea外の領域はなく、解像度は1024x768としましょう。
高さが増したので、青楕円が大きくなっています。「縦幅の半分」という定義だからです。 一方、キャラ絵が画面に占める比率は小さくなりました。
次は、2436x1125で画面端が欠けた端末にしてみましょう。iPhoneXです。 左右に132ピクセル、下に63ピクセルはモノを置くと危険な場所です。
周囲の赤い枠は、切り欠き等で使えない部分だと思ってください。 いわゆるsafeAreaの外です。 青楕円はボタンがSafeAreaの左端に寄せて配置され、楕円は その外にも描画されています。safeAreaとはいえ、 そこで切れてしまったらかっこ悪いからです。 キャラ絵は今はsafeArea無視で右下寄せで配置しているので、 いくらかがsafeArea外に出ています。 実際の製品ではキャラ絵は中に入れた方がいいかもしれませんね。
ついでなので、もっと極端な解像度も試してみましょう。432x768、 つまり縦長です。
せっかくなので、左10、右20、上30、下40ピクセルをsafeArea外としてみました。 青がもっと大きくなっています。緑が小さくなったのは「幅は画面幅の半分」 という定義だからです。キャラ絵も小さくなっています。 さすがにこうなると手間ゼロで対応、というわけには行かなそうですね。 しかし、このケースですら、 「画面からはみ出る」「いけないものが映ってしまう」「塗り残す」 といったことは起こっていません。
さて、当然ですが、この4つの解像度のスクショを取る際に、 いちいちレイアウトをいじったりはしていません。 GameViewの解像度を変え、inspectorにsafeAreaを定義する数個の数字を入れただけです。 弊社東京プリズンでは、こういったレイアウト要素の拡大縮小と配置を 自動化するコンポーネントを使っており、 多解像度対応のコストを減らしています。
今回はこの実装と運用について書いてみようと思います。なお、 コンポーネントのソースコード を含めた サンプルコードはgithubにて公開しています。
今回紹介するものの使用法
おそらくゲームを作っている人ならば、この手のものを何かしら作っているとは思うので、 今回のコードの使い方を細かく説明しても意味はないのですが、 使い勝手からわかることも多々あるかと思いますので、軽く説明いたします。
なお、ここで紹介するものは東京プリズンでの実装をベースにしてはいますが、 そのままではありません。サンプルとして切り出すために変更した点や、 より改良した点があります。
まずコンポーネントをつける
まずやることは、RectTransformScalerというコンポーネントを、 レイアウト要素の根にあたるgameObjectにつけることです。 例として左寄せの青い楕円について見てみます。
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でおかしくならないようにするならば、 かなり画面の外側まで枠を伸ばさないといけなくなり、素材の容量も大きくなるし、 見栄えも悪くなります。 そこで、東京プリズンでは「あまり横長であれば端が出てくる」というデザインにしています。
プレビュー
エディタ拡張で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が混ざるとまた厄介な点があります。
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クラスに足したボタンがその機能を持っていますが、 実際には様々なシーンで用いるので、このように右クリックメニューに加えておきました。 ただ、作っておいて言うのもなんですが、なくてもあまり困らない気はします。
まとめ
多機種で動くアプリケーションを作る際には、大抵解像度やアスペクト比のバラつきが 問題になります。iPhoneXのおかげでさらにそれがややこしくなっており、 ある程度の手間は見込んでおかないと後でひどい目に遭います(遭いました)。
最適なレイアウトのためにはデザイナーや企画との協力が欠かせませんが、 とりあえず「多少の多様性」に関してはプログラマ側で ちょっとした仕掛けを作ることで吸収はできます。 そういった仕掛けの一例として、このようなものを紹介いたしました。
ご意見、ご感想等いただけると幸いです。 また、他の製品での事例も公開していただけると 私を含めて多数の人が幸せになると思いますので、他社さん検討よろしくおねがいいたします。