3Dゲームで丁度よく画面に収めたい!自動で!

動画をクリックするとWebGLビルドに飛びます。 また、ソースコードはgithubに置いてあります

こんにちは。技術部平山です。

今回は、3D空間にある物をいい具合に画面に収めるための計算について説明します。 動画では16個の球が好き放題動き回っており、 カメラはこれが全部画面に入るように追跡しています。 補間計算のために少々遅れがあってはみ出すことがありますが、 おおむね収まっていることがわかります。

17年かかってしまった...

実は、この問題に初めて出会ったのは17年も前のことです。 その時は時間がなかったので、ずいぶんテキトーな近似で良しとして、 真面目に考えることをしませんでした。

その後も数回この問題に当たる機会があったのですが、 やはりその度にテキトーな近似と手調整でその場しのぎをしてしまい、 今日に至るまでイマイチな方法しかないままで来てしまったのです。

こんなのwebにいくらでも回答あるでしょ、 と思いますし、今でも「きっとあるんだろうな」と思うのですが、 何故か見つけられていません。 つい先日またこの問題に当たった際に、 「今度は真面目にやるか」と思って考えてみたところ、 あっさり今までよりマシなものができたので、ご紹介いたします。 正直、今となっては、なんで最初にこれを思いつかなかったのかわかりません。

問題の定義

3D空間中に、画面に収めたい複数の点が存在しているとします。 これが丁度画面に収まるようにカメラのパラメータを決める、 という問題です。

動的に変化し得るカメラのパラメータは以下の3つがあります。

  • 位置(Vector3)
  • 向き(オイラー角ならVector3)
  • 画角(field of view)

実際の状況では、このうちの何かが固定されている状態で、 他の何かを動かす、ということになります。 例えば、画角を動かさずに位置だけで合わせたり、 位置を変えずに向きと画角で合わせたりします。

さて今回は、「向きと画角を変えないまま位置だけを動かす」 方法です。つまり、求めたい数は、位置のx,y,zで3つです。

カメラが固定されている状況では、 「向きと画角を変える」方法が必要になりますが、 それはまた今度考えることにしましょう。 リアル系のゲーム(スポーツなど)であれば、 カメラは固定の方がリアルっぽいので、そちらの手法が必要になります。

まず2次元

3次元は難しいので、まずは2次元で考えます。

f:id:hirasho0:20190719193849p:plain

カメラ位置c、カメラの正面ベクトルe、点をとりあえずP0,P1,P2の3つ、 画角θを書いてみました。

今は向きは固定なので、eは固定です。 決めたいのはCですね。 この絵で言えばP0が視界の上端にピッタリ来るように若干下に動かし、 P2が下端に来るように前に動かし、という感じでしょうか。

さて、問題はそれをどうやって計算としてやるかです。

いらない情報を捨てる

未知数はCの座標で、2次元なので中に2つ数値があります。 ということは、式は2本あるはずだ、 ということになります。点が今3個ありますが、 点1個につき式が1つ立つとすれば、1個は余計です。

実際、図を見ればわかるようにP1はあってもなくても影響がなさそうですね。 というわけで、最初にやることは、いらない情報、つまりP1を捨てて、 P0とP2を残す作業です。あとは残った2点で式を立てて、 連立方程式を解けば、2つの解が出てくると。 そういう筋書きになるという予測が立ちます。シンプルですね。 なんで昨日よりも前の私にその筋書きが見えなかったのか、本当わかりません。

さて、集合から情報を捨てる計算といえば何でしょうか? 真っ先に思いつくのは最大と最小です。 集合の最小は1つ、集合の最大は1つなので、 最小と最大を取れば、ある集合から2個のデータを残して捨てられます。

図で言えば、上端に来そうな点P0を取ると何かが最大になって、 下端に来そうな点P2を取ると何かが最小になる、 そんな関数があれば、P0とP2以外を捨てられます。 P1以外にP3,P4,P5...が間にあっても同様に捨てられるわけです。

上下軸で捨てられる?

では、上下軸でしょうか。元々の問題は3次元なので、 z軸は前後軸として残し、 もう一つの軸をxかyにしましょう。上から見るか横から見るかなのですが、 画角が縦方向なのと、図に描いた時に「上はY」と言えた方がわかりやすいので、 ここでは横から見たことにして、Y軸を取ります。 eの方向がZ軸、図の上方向がY軸ということにしましょう。 つまり、Yが最大のものと最小のものを取ればいい、ということになるでしょうか。

f:id:hirasho0:20190719193836p:plain

ダメでした。

この図ではP1の方がP2より下にありますが、使うべきはP2です。 つまり、単純にY軸の位置だけでは決まりません。

では何で決まるのか?これです。

f:id:hirasho0:20190719193839p:plain

視界の下端と同じ斜めの線を 各点に引いて、それがCを通るY軸に当たる所のyの値を作り、 これで比較します。この場合は最小のものが、 視界の下端で使うべき点だとわかります。 y2はy1より下にありますから、使うのはy2を作る元になったP2ですね。

Y軸に射影した点を求めて最大最小を取る

では、y1とy2を求める方法を考えます。

とりあえず、今の段階ではP0,P1などはワールド座標で eもワールド座標ですから、 汚ない数字が入っていて扱いにくいですね。 座標変換してしまいましょう。

Cが原点、eがZ軸プラスすなわち(0,0,1)になるように P0,P1...を座標変換したとすれば、話はあっさり簡単になります。

f:id:hirasho0:20190719193843p:plain

Z軸が右向きの方がわかりやすいので図をひっくり返しました。 図のPから斜め線を引いてY軸との交点を求める問題で、 斜め線の傾きa(y変化量/z変化量)がわかれば、

y = p.y - (a * p.z)

とあっさり求まります。下の斜め線の場合傾きaはマイナスですね。 全ての点についてこのxを求めて、一番小さかった奴が、 「視界で一番下に来る奴」ということになります。 上の斜め線についても同じようにyを求めて、 今度はyが一番大きい奴を探します。それが「視界で一番上に来る奴」 です。

コードにする

ここまで来ればコードにできます。

まずは全部の点をカメラのローカル座標に移してしまいましょう。 カメラのローカル座標のことを「ビュー座標」と呼ぶことが多いので、 ここでもそう呼びましょうか。

var toView = camera.transform.worldToLocalMatrix;
var pointsInView = new Vector3[points.Length];
for (int i = 0; i < points.Length; i++)
{
    pointsInView[i] = toView.MultiplyPoint3x4(points[i]);
}

これでビュー座標に点が移りました。点を座標変換する時はMultiplyPoint3x4を使います。

次に、傾きaを求めます。下の線のマイナスな傾きをa0、上の線のプラスな傾きをa1とすると、 a0とa1は単純に符号違いですね。上下対称ですから。 プラスのa1の傾きは、(yの変化量)/(zの変化量)で、 これってtan(θ/2)です。

var a0 = Mathf.Tan(camera.fieldOfView * 0.5f * Mathf.Deg2Rad); // ラジアンに直す
var a1 = -a0;
var yMin = float.MaxValue;
var yMax = -yMin;
foreach (var p in pointsInView)
{
    var y0 = p.y - (a0 * p.z);
    var y1 = p.y - (a1 * p.z);
    y0Min = Mathf.Min(y0Min, y0);
    y1Max = Mathf.Max(y1Max, y1);
}

これでyMinとyMaxが求まりました。あとは、斜め線2本の交点がカメラの位置です。

f:id:hirasho0:20190719193845p:plain

図中でu番の点であるPuからy1Maxが生まれ、 v番の点であるPvからy0Minが生まれた、という図です。 ここから連立方程式を立てましょう。 Pvを通る直線は、y=y0Min + a0*zで、Puを通る直線は、y=y1Max + a1*zです。 このyが等しくなる点なので、yを消去して、

y0Min + a0*z = y1Max + a1*z

zが入っているものを左に、それ以外を右に移すと、

(a0 - a1)*z = y1Max - y0Min

つまりzが解けて、

z = (y1Max - y0Min) / (a0 - a1)

となりました。zが出ればyも出るので、これでy,zが求まったことになります。

var z = (y1Max - y0Min) / (a0 - a1);
var y = y0Min + (a0 * z);

Xはどうする?

さてYZ平面での計算は終わったんですが、 Xはどうしましょうか? 実は同じことをX軸についてもやります。

傾きを求める必要がありますが、a0、a1はさっき使って紛らわしいので、 ax0とax1としましょうか。さっきのa0、a1はay0、ay1と改名しましょう。

X軸、つまり画面の横方向は、縦に比べてアスペクト比倍の画角があります。 例えば縦画角が90度の時、その半分は45度ですから、傾きであるtan(θ/2)は1です。 画面が16:9で、横が16/9倍長い場合、この値に16/9を掛けるので、 傾きは16/9になります。

var ax0 = ay1 * camera.aspect;
var ax1 = -ax0;

アスペクト比はCamera.aspect で取れます。16:9の横長なら約1.78、16:9の縦長なら約0.56が入っています。

これで同じように最小最大を求めていらない点を捨て、 xとzを同じように求めます。

求まったら、Y軸で求めたzと、X軸で求めたzを比べ、 小さい方を取ります。 もし、Y軸で求めたzが、X軸で求めたzより大きければ、 Y軸でやった時の方がカメラを前に出せる、ということです。 そうするとX軸では収まり切らないので、X軸を優先します。 小さい方、つまり手前を取るのはそういうことです。 そして、yとxはそのまま使います。それが中央だからです。

最後に、今求めたxyzはビュー座標系、つまりカメラのローカル座標での話なので、 これをワールド座標に戻して完成です。

以上からコード全体は、以下のようになります。

public Vector3 Focus(Vector3[] points)
{
    // 全点をビュー空間に移動
    var toView = Camera.transform.worldToLocalMatrix;
    for (int i = 0; i < points.Count; i++)
    {
        points[i] = toView.MultiplyPoint3x4(points[i]));
    }

    // 各種傾き
    var ay1 = Mathf.Tan(Camera.fieldOfView * 0.5f * Mathf.Deg2Rad);
    var ay0 = -ay1;
    var ax1 = ay1 * camera.aspect;
    var ax0 = -ax1;

    // XY軸で最小最大を取って不要データを捨てる
    var y0Min = float.MaxValue;
    var y1Max = -float.MaxValue;
    var x0Min = float.MaxValue;
    var x1Max = -float.MaxValue;
    for (int i = 0; i < points.Count; i++)
    {
        var p = points[i];
        var by0 = p.y - (ay0 * p.z);
        var by1 = p.y - (ay1 * p.z);
        var bx0 = p.x - (ax0 * p.z);
        var bx1 = p.x - (ax1 * p.z);
        y0Min = Mathf.Min(y0Min, by0);
        y1Max = Mathf.Max(y1Max, by1);
        x0Min = Mathf.Min(x0Min, bx0);
        x1Max = Mathf.Max(x1Max, bx1);
    }
    // zを2つ求め、小さい方を採用する。x,yはそのまま使う
    var zy = (y1Max - y0Min) / (ay0 - ay1);
    var y = y0Min + (ay0 * zy);
    var zx = (x1Max - x0Min) / (ax0 - ax1);
    var x = x0Min + (ax0 * zx);
    var posInView = new Vector3(x, y, Mathf.Min(zy, zx));

    // ワールドに戻す
    return Camera.transform.localToWorldMatrix.MultiplyPoint3x4(posInView);
}

サンプルでの使い方

サンプルコードでは、CameraControllerというクラスのメソッドとして用意してあります。 インターフェイスは上とは若干異なり、もらった点の配列をそのままいじらず、 内部的に持っているテンポラリのListにコピーして計算しています。 できるだけ引数にもらったものは変更しない方が良いでしょう。

また、CameraControllerは補間の機能を持っており、 以前の記事 で紹介した指数関数補間を使っています。なので、ほどほど滑らかに動くわけです。

機能拡張について

収めたいものは点とは限りませんよね。 例えば球であったり箱であったりします。 しかし、いずれの場合も点の処理ができれば、それを利用して解くことができます。 球の場合は上下左右前後で6点くらい取れば実用上は十分ですし、 箱の場合は角の8点を生成すれば同じことになります。

また、「画面の外側10%は余裕を取りたい」 「画面下部と右側にはUIがあるので、それを除いた部分に入れたい」 といった要望もあるかと思いますが、これはわずかな改造で可能になります。 傾きを計算する所で、画面端の残したい領域を考慮するだけです。

終わりに

びっくりするくらい簡単でしたね。 何で思いつかなかったのかさっぱりわかりません。 球で近似して無駄に画面を余らせたり、 ビュー座標系での直方体で近似してやはり画面を余らせたり、 妙に無駄なことをしていました。

「向きを固定する」ということで何がどれだけ簡単になるかを把握できていなかったことと、 「最大最小はデータを捨てる手段である」という言語化、 視界端の平面を使って最小最大を取る値を作る、というあたりの思いつき、 などが何故か出てこなかったせいでしょうが、 なんとも不思議なものです。

スマホ向け3Dゲームにおける「とりあえず」のレンダリング設定

画像をクリックするとWebGLビルドに飛びます。

こんにちは。技術部平山です。

この記事は、スマホ向けに3Dゲームを作る際に 「とりあえず」やっておく描画設定について考えたり試したりしたことの記録です。 設定の項目は主に、

  • ライトマップ
  • ライトプローブ

といったあたりになります。

そして、これらの設定について考えようと思うと、 何をどうやっても照明計算の知識が必要になりますので、 それについても触れることにします。

動機

最近3Dゲームの試作をやる機会があるのですが、 テキトーに立方体や球をいくつか置いただけでも、 私のスマホ(京セラS2)では満足なフレームレートが出ません。

DrawCallもマテリアル設定も少ないし、 大して重いコードを書いたつもりもないので、CPUが遅いわけはありません。 実際プロファイラをつなぐと1フレームあたり4msで終わったりしています。

となると、犯人はGPUです。 GPUと言えば、頂点処理とピクセル処理ですが、 頂点が多いはずはないので、完全にピクセルです。 S2の画面解像度は1280x720もあり、GPU性能を考えれば この全画素に3Dの照明計算をするのはかなりキツいでしょう。 10年以上前の話とは言え、 PLAYSTATION3のような据置きゲームですら耐え難かった解像度です。 当時は2DのUIだけ1280x720で描画して、3D部分はもっと低い解像度で描画して合成する 「解像度詐欺」と(一部で)呼ばれる手法が普通に行われていました。

Unityは勝手にいろいろやってくれるわけですが、 さすがに性能が下の方のスマホにはあまり配慮してくれていません。 普通に作ると結構豪華なシェーダがバンバン走ってしまいます。 「こりゃちょっと真面目に考えないとダメだな」と思ったのです。

照明計算の概略

ここでは、3DCGにおける照明計算を、ひどく乱暴に説明しましょう。

ゲームのようにリアルタイムで動かさないといけない場合、 照明計算は粗く言って以下の6要素の計算結果を足して行われます。

  • 直接光の拡散反射
  • 直接光の鏡面反射
  • 間接光の拡散反射
  • 間接光の鏡面反射
  • 環境光の拡散反射
  • 環境光の鏡面反射

「直接」「間接」「環境」の3つの種別と、「拡散」「鏡面」の2つの種別の組み合わせで6通りです。 直接であろうが間接であろうが光は光ですし、 表面の性質が千差万別なので反射の仕方を2つに分類するのは乱暴なのですが、 少し前までのゲーム開発において 「耐えられる計算量でマシな絵を出そうと工夫した結果」がこの分類だ、 とお考えください。

光の経路による分類

「直接(Direct)」というのは、光源から直接光が飛んできた時のことです。 太陽があって、何にも遮られずに地面に当たれば、これが「直接」ということになります。

一方「間接(Indirect)」というのは、 一回どこかで反射したり屈折した光に照らされた時のことです。 太陽の光が赤い壁に当たって反射し、それが地面に当たって赤く染める、 みたいな奴ですね。

そして、「環境(Environmental)」というのは、空や風景など、 「遠く」の「全方位」から来る光のことです。「十分遠い」ので、 シーンの中で動き回っても変化しないものとみなせます。

反射の分類

反射には、「拡散反射(Diffuse)」と「鏡面反射(Specular)」があります。

拡散反射

「拡散」というのは、粘土みたいにテカテカしていない時の計算方法です。 表面がザラザラなので当たった光がいろんな方向に反射し、 どこから見ても同じに見える、とします。

なお、真面目にやる場合は、金属でないものでだけ計算します。 金属に拡散反射はありません。

鏡面反射

一方「鏡面」というのは、テカッキラッとした感じの奴です。 マンホールに夕日が当たってすごい反射したけど、 少し頭を動かすとまぶしくない、というアレです。 どこから見ているかで見え方が大きく変わります。

そして、物の色と関係なく、光の色を反射します。 黒っぽいマンホールも、太陽を反射すれば黄色く光ります。 真面目にやる場合、金属はこの鏡面反射しかしません。

なお繰り返しますが、この分け方は便宜上のもので、 実装の都合、機械の性能、やりたい表現などなどによって、 全然違ってきたりもします。 ただ、UnityのStandard Shaderを理解する上では、 まあまあ役に立つ分類と言えるでしょう。

6種類の特性

ではこの6種類の特性をそれぞれ見ていきましょう。

1. 直接光の拡散反射

f:id:hirasho0:20190719135922p:plain

斜め上にある太陽を向いているあたりが明るく、反対は暗くなっています。

直接光なので、経路は一つしかありません。 どこかで反射したり曲がったりすることは考えないからです。 処理負荷は光源の数に比例します。

また、「どこから見るか」によって色が変わらないので、 光源が動かず、物も動かないのであれば、「前もってテクスチャに保存しておいて (「ベイクする」「焼く」と言う)実行時はそれを使うだけ」という最適化ができます。 これがライトマップ です。

なお、光源からの経路が何かに遮られていれば、そこは影になります。 これを計算するのは結構重い処理です。シャドウマップという処理が必要になり、 全てのものを、余計に一回づつDrawCallすることになります。

しかしこれも「光源が動かず」「物が動かない」のであれば、 影もろともライトマップに焼くことができます。 この場合、実行時の負荷は影があろうがなかろうが変わりません。

2. 直接光の鏡面反射

f:id:hirasho0:20190719135925p:plain

テカテカと太陽の光が映り込んでいますね。

直接光なので経路は一つしかないのですが、 拡散反射と違って「どこから見るかで大きく変わる」という特性があります。 普通3Dゲームで視点が固定ということは滅多にないので、 基本的にはライトマップに焼けないと考えて良いでしょう。 ただし、「少しくらいなら動いても気にしない」という割り切りをして 焼いてしまう選択もありえます。

また、キラッといい感じに光らせるための式が結構複雑なので、 それなりに重くなります。StandardShaderの場合、 マテリアルにこれを有効化するかどうかのフラグがあります。 "Specular Highlights"というのがそれです。 あんまりテカテカしてない材質で、画面に写る面積が大きなものは、 切ってしまってもいいかもしれません。

それと、鏡面反射で出てくる色は「物の色とは関係ない」という特性があります。 赤い物だろうが黒い物だろうが、光が白ければ白です。

なお、影に関しては拡散反射と同じです。普通は同じシェーダで一緒にやります。

3. 間接光の拡散反射

f:id:hirasho0:20190719135935p:plain

左に緑の壁があるため、左を向いた面は緑に染まっています。 逆に、右に赤い壁があるため、右を向いた面は赤く染まっていますね。

さて、赤い壁や緑の壁、といった光源から光が届く経路は無限にあります。 そもそも、壁には面積がありますから、壁のどこから来た光か? というだけでも選択肢は無限です。 実際には数々の近似を駆使して高速化するとしても、 スマホで実行中にこの計算を毎フレームやるのは無理です。

ただし、拡散反射ですので、どこから見るかで色が変わりません。 ということは、「光が動かず」「物も動かない」ならば、 ライトマップに焼くことができます

さてこれにも影があります。 正確に言えば「陰」です。 太陽や蛍光灯のようなはっきりした光源ではなく、 全方位からいろんな光が来るので、はっきりした影ではなく、 陰影といった感じになります。 例えば、奥まった所は暗いですよね?鼻の穴の中は黒く見えます。こういうのも、 ライトマップに焼くことができます。

また、動くものでもこの計算をしたい! という場合には「このへんにいる時には周囲から来る光はこんな感じ」 というのを計算してたくさん保存することで、代わりにすることもできます。 UnityではこれをLightProbe と呼びます。

動く物に対して間接拡散反射の計算をするのが、LightProbeです。 場所によってLightProbeのデータが明るかったり暗かったりすれば、 奥まった所に入った人が暗く見え、外に出たら明るく見える、 というようなことができるわけです。

4. 間接光の鏡面反射

f:id:hirasho0:20190719135938p:plain

ところどころ、赤い所、緑の所、白っぽい所がありますね。 赤い所は左の赤い壁からキラリと反射したもので、 緑は右の壁から来たものです。白は空でしょう。 このように、キラリと周囲が映り込みます。

これも光源は無数にあり、経路も無数にあり、 さらにどこから見るかで色が激しく変わるので、 ライトマップに焼くことはできません。

ただ、間接拡散反射の時と同じく、 「このへんにいる時は周囲から来る光はこんな感じ」 というデータを前もって計算しておけば、 それなりにそれっぽいことができます。 UnityではこれをReflectionProbe と呼びます。

ただし、一個あたりのデータ量がLightProbeより格段に大きいので、 あんまりたくさんは置けないでしょう。 テカッテカの金属オブジェに、周囲の風景が写り込む、 みたいなことをやりたい時に使います。

5. 環境光の拡散反射

f:id:hirasho0:20190719135930p:plain

全体に青っぽいですね。ほぼ空の色です。

これは、「十分遠く」の「全方位」からやってくる光による、拡散反射です。 光源になるのは空や地面ですね。 地面は空の光を反射しているので、地面が光っているわけではありませんが、 地面が光っていることにしてしまうと楽です。

例えば、空からは青い光が来て、太陽があるあたりからは黄色い明るいのが来て、 地面は草なので暗い緑が来る、といった感じになります。 どこから見るかに依存しないので、「物が動かないのであれば」 これもライトマップに焼くことができます。

欠点としては、影が出せない、ということがあります。 もし何もケアしていなければ、「屋根の下にいるのに空の光を受ける」 といったことが起きてしまいますが、 そういう場合はLightProbeを置いて代わりに使えば良いでしょう。

たぶん環境拡散反射は 「デフォルトで世界に一個だけ置いてあってOffにできないLightProbe」 として実装されており、 LightProbeがある時にはこのデータを使っていない気がします(厳密な確認はしてない)。 逆に、ステージ全体が開けていて、 色のついた発光物が置いてあったりしないのであれば、 LightProbeなしでも良い気がします(スポーツのスタジアムとかはいい例です)。

なお、「十分遠いとみなせる光源」は全部ここにつっこめるので、 太陽や室内の蛍光灯、街灯なんかも入れてしまえば、 直接光の計算を環境光の計算に含めてしまうこともできます。 ただし影は出せないので、太陽のように明らかに明るい光源を含めるのはおすすめできません。 たくさん光源がある複雑な場面(夜の街とか)で、 くっきりした影がなくても気にならない場合には良いでしょう。

Lighting SettingsのEnvironmentの項目に、 Environment Lightingというものがあり、これで設定します。

6. 環境光の鏡面反射

f:id:hirasho0:20190719135933p:plain

かすかに青白い所があります。これは空の映り込みです。 もしSkyboxのマテリアルでパノラマ写真みたいなものを空として返すものを用意すれば、 周囲がテッカテカに映った絵が見られるでしょう。

これは、「十分遠く」の「全方位」からやってくる光による鏡面反射で、 拡散環境反射と同じく、光源になるとのは空や地面です。 ただし拡散反射と違って、物体の色に関係なく光の色が出ますし、 視線や位置によってどこが映り込むかが変わります。 ライトマップには焼けません。

なお、たぶんですが、 実装は「デフォルトで一個置かれてOffにできないReflectionProbe」 ではないかと思います。 MeshRenderer側でReflectionProbeがOnになっていて、 実際にReflectionProbeが置いてあれば、 そちらが代わりに使われるようです。計算の中身は同じなのでしょう。

特徴をまとめてみる

直接光の拡散反射 直接光の鏡面反射 間接光の拡散反射 間接光の鏡面反射 環境光の拡散反射 環境光の鏡面反射
焼ける? Yes No Yes No Yes No
重い? 軽い×光源数 重め×光源数 無理 無理 軽い 軽い
実行時に影出せる? Yes Yes No No No No
実行時に光変化できる? Yes Yes No No 重いがYes 重いがYes

雑ですが、だいたいこんな感じでしょうか。

設定の方針

動かないものは焼け

一番大事なことはこれじゃないでしょうか。

普通のゲームでは、画面の大半は風景が写っているはずで、 人のように動くものの占める面積はそれほど大きくありません。

フラグメントシェーダの負荷は面積に比例しますから、 地面や建物、山、空、といった風景を焼けるかどうかで 大きく負荷が変わります。 動くものが占める面積が小さいゲームほど、焼くことの効果は大きいわけです。 逆に、画面の真ん中にデカいドラゴンが陣取って動き続けるようなゲームだと、 あまり効きません。

さて、Unityでライトマップ焼きによる負荷軽減を行うには、 gameObject側の設定をstaticにする必要があります。

f:id:hirasho0:20190719135957p:plain

staticにしておけば、あとはLightingSettingsの設定次第で ライトマップが作られて、照明計算が軽くなるわけです。

焼くことの欠点

焼くことで、到底実行時には計算できない複雑な間接光の計算ができるようになり、 できる絵は格段にリアルになるのですが、欠点も多数あります。

まず、容量が増えます。 できるデータはテクスチャであり、ステージが広ければ広いほど、 ステージに物がたくさんあればあるほど、 そして、品質を上げれば上げるほど容量を食います。 さらに、光源の設定ごとに容量が増えます。 「同じステージで太陽の位置と色を変えたデータを8種類焼く」 となれば、8倍の容量となり、実質無理です。 「光の具合だけ変えて朝、昼、夕方、夜のステージを安く作ろう」 なんて考えると、途端に容量がヤバいことになります。

次に、焼きに時間がかかります。 品質の高い焼きデータを作るには時間がかかり、 開発効率を落とします。 開発時は低品質で焼いて感じを掴み、 完成前に本番焼きをする、といった運用になるでしょう。

そして、最大の欠点が、実行時に物や光源を動かせなくなることです。 太陽がじわじわ動いて、夕方になると赤くなって影がのびてくる、 みたいな仕様があるなら、この手は取れません。 建物が壊れる、建物を動かす、実行時に建物をランダムに配置する、 といったこともできません。

そういったことがどうしても必要であれば、 焼くのは諦めた方が良いでしょう。 細かな陰影を焼なくても良い絵柄(アニメ調とか)を模索するのも良いかもしれません。

あるいは、Ambient Occlusion のような手法で、多少なりとも焼きに近い品質に近づける手もあります。

とはいえ、スマホ向けで多少なりとも背景にリアルさが欲しいのであれば、 「とりあえず焼く」という方針が無難なのではないでしょうか。

影設計

焼けるものは焼く、と決めても、 焼けるのは動かないものだけです。 加えて、動くものと動かないものの相互作用の問題があります。 その最たるものが影です。

動くものと動かないものの間の関係は、以下の4つです。 なお、わかりやすさのために、「動かないもの」を「建物」か「地面」、 「動くもの」を「人」と言い換えて説明します。 その方がイメージが伝わりやすいでしょう。

1. 人が、人に、影を落とす

シャドウマップ法を使って、毎フレーム計算するのが標準的ですが、 DrawCallが倍になる上に、影判定のシェーダ負荷も結構キツいです。

シャドウマップ法に耐えられない機械を対象にする場合は、 この要素は捨てることになります。 そういう場合は、直接光を弱くして曇りの日のような感じにすると、 影がないことをゴマかしやすくなります。

2. 人が、地面や建物に、影を落とす

地面だけで良く、地面が平面であれば、 モデルの頂点を地面に射影して黒く塗ることで、比較的軽く影を描けます。 それすら耐えられない場合は、丸いぼやけた板を地面に置く「丸影」が良いでしょう。 地面に影が落ちないと接地感がなくなってゲームに支障を来すので、 何もやらないわけには行かないのです。 これらの手法を使う場合、QualitySettingsのShadowsの項目で、 影をDisableにしてしまい、自力でどうにかすることになるのでしょう。

地面以外にも影を落としたい、あるいは地面が平面でない、 という場合には、標準のシャドウマップ法で行うことになります。 DrawCallが増える上に、影判定のシェーダ負荷が 動かないものにもかかってくるので、かなり負荷は上がります。

さらに、正確性の問題もあります。 動かないものの照明計算はもうライトマップに焼いてあるのです。 すでに建物の影になっているのか、そうでなく日向なのかが、 ライトマップからはわかりません。すでに影なら、そこに人の影が落ちても それ以上は暗くならないはずですが、そういう区別ができないのです。

しかし、余計な計算を増やしたくないのであれば我慢するしかなく、 動くものの影になった部分は、すでに影であろうがなかろうが、 「一定値を引く」ということになります。 Unityの場合Lighting SettingsのMixed Lightingの設定を、 subtractiveにしておけばそうなります。 影がどんな色になるかは固定で設定します。

f:id:hirasho0:20190719135949p:plain

影の中に影がある例です。わかりやすいように影の色を緑にしておきました。 ゲーム的に、キャラの位置がわかりやすくなるので、 絵としてはおかしくてもゲームとしてはアリ、ということもあります。 もうちょっと大人しい色にすれば気にならないかもしれません。

f:id:hirasho0:20190719135951p:plain

影の色をUnityのデフォルト値にしてみました。どうでしょう? ...すみません、私はかなり気になります...

もちろん追加の負荷を払えば、人の影が日陰では落ちないようにできます。 UnityではMixed Lightingの項目をshadowmaskにするだけです。 おそらく「元々日陰かどうか」を追加情報として持つのでしょう。 ただし、もちろん重くなります。

3. 建物が、人に、影を落とす

LightProbeで多少はやれます。 それで足りないとなれば、建物その他をシャドウマップに 描くしかなく、かなり計算が増えます。 「とりあえず」の設定としてはナシでいいでしょう。 製品としてどうしてもということであれば、何か考えることになります。

4. 建物が、建物や地面に、影を落とす

ライトマップに焼かれているので問題ありません。 ただし、くっきりした影を焼こうとすると解像度の高い焼きデータが必要になり、 容量と計算時間の両面で大変ですので、 基本的にはボヤけた影で我慢することになります。

ではどうするか?

建物が地面に落とす影が焼いてありさえすれば、 あとゲームとして重要なのは、人が地面に落とす影です。 これがないと物の位置関係を把握できません。 丸影などの古代の手法はUnityでやると面倒くさいので、 スイッチ一つで行けるシャドウマップ法でどうにかしたいところです。

絵がウリのゲームであればもっと上を目指したいところですが、 そういう製品はたぶん安物のスマホを切り捨てると思うので、 SSAOでも使えば良いかと思いますし、 Realtime Global illuminationといういかにもすごそうなチェックボックス がありますので、Onにしてみるのが良いのでしょう。

ただ、「影の中に影」はちょっと悲しいですね。 負荷を見て耐えられるならShadowmaskにしてしまうのが良いのでしょう。

地面や建物の鏡面反射は悩み所

人間の視覚はコントラストが強い部分で形を把握します。 だから、鏡面反射による高いコントラストがあるかないかは、 かなり印象を左右するのです。 しかし、鏡面反射は視線に依存するので焼けません。 そして結構重いのです。

Lighting Settings - Mixed Lighting - Lighting Modeを Subtractiveにすると、影の中に影が落ちるのみならず、 staticなものの直接鏡面反射が一切計算されなくなります。 結構軽くなるはずですが、見た目は相当寂しくなります。

実際製品を作るとなればかなり考えるとは思いますが、 今回の話は「何か試作する時にとりあえずやっとく設定」 という程度なので、Subtractiveにして鏡面反射ナシ、 というのが良いかなと思います。 重い物を作ってしまって後で軽くするよりも、軽くしておいて要素を足していく方が 大抵は良いのです。

重い良い絵に慣れてしまうと、削りたくなくなってしまいますし、 良い絵にするにあたって調整その他で結構手間をかけてしまっているはずです。 それを削っていくのは無駄も多いし、心理的にも苦しい作業になります。

LightProbeの配置

まず、ステージが開けたゲームならLightProbeなしで始めていい気がします。 スタジアム状の場所でキャラが動くゲームであれば、 障害物があったり入り組んだ場所があったりはあまりしないでしょう。 LightProbeを置くのは結構面倒くさいので、ナシでいい気がします。

ただ、キャラに建物の影が落ちるとか、奥まった所にキャラが入っていけるとか、 ステージに焚き火や街灯がいくつもあって、キャラがその近くに寄っていけるとか、 そういう話になると、場所によって明るさが変わる要素がないと 寂しくなりすぎます。

初期配置用ツール

今回は、初期配置用として、雑にLightProbeを置くスクリプトを用意しました。

f:id:hirasho0:20190719135941p:plain

LightProbePlacer.csというエディタスクリプトから実行でき、 等間隔でLightProbeを置くだけです。 本当は、光が急激に変わる所に密に置き、そうでもない所はまばらに置く、 というケアが必要なのですが、どうせ最終的に手調整になるなら そこまでしなくてもいいでしょう。雑に置いても感じはわかります。

LightProbeの注意点

なお、公式のドキュメントにもありますが、 LightProbeはあまり密に配置しすぎると、結果がおかしくなります。 「動く物の大きさより大きな間隔で置く」が基本です。 例えば人が半径1メートル、高さ2メートルの円柱くらいの大きさなのであれば、 水平間隔を1メートルよりは大きく取らないとおかしくなります。

f:id:hirasho0:20190719140430p:plain

中央にやけに暗い球がありますが、これ何が起こってるかわかりますか?

このシーンは真ん中に円柱があります。そして、 たまたまテキトーに置いたLightProbeの一つが、 この円柱の中に配置されてしまいました。 円柱の中ですから、周囲から光は全く来ず、真っ暗になります。

この大きな球の中心座標に一番近いLightProbeはその真っ暗なものでして、 それが球全体に適用されます。すると、こういうことになるわけです。 これをわずか1m動かして円柱から出してやると、こうなります。

f:id:hirasho0:20190719140423g:plain

そういうわけで、LightProbeの置き方は動く物の大きさとも関係するので、 簡単ではないわけです。今回の例のように「立体の中に入って真っ暗になってしまう」 のも避けないといけません。

巨大なモンスターと戦うゲーム、なんてものを作る場合はかなり考えないといけないでしょう (私だったらLightProbeが不要な開けた空間でしかやらないと決めてしまうでしょうね)。

設定の実際

では設定の実際です。

Lighting Settings

  • RealTime Lightings
    • RealTime Global Illumination
      • どう考えても重そうなので、とりあえずOffです。 私のスマホで測ったらそれなりに重くなりました。
  • Mixed Lighting
    • Baked Global Illumination
      • onにするとライトマップが焼かれます。 動かないものはこれでできるだけどうにかしてもらいましょう。
    • LighitngMode
      • とりあえずSubtractiveにして様子を見ています。影の中に影が落ちて辛い、となればshadowmaskにすることもあるでしょうが、やはり重くなります。絵を作り込む段階になったら考えるでしょう。
  • Lightmapping Settings
    • Direct Samples
      • 少ないほど焼きが速いので、開発中は下げておきたいところです。2でも感じはわかります。シーンに置く物が出来上がって、ライトの角度や色が決まったら増やして本番焼きをしましょう。
    • Indirect Samples
      • 8が最低です。マニュアルには10とありましたが、8にできますね...
    • Environment Samples
      • 8が最低です。コードで設定する方法が見当たりません...
    • Lightmap Resolution
      • シーンが広くて複雑なほど下げないとテクスチャ容量が大きくなります。 Unity単位1あたりのピクセル数なので、例えば1を1mとしているゲームで、 2に設定すれば、50cm精度で焼く、ということになります。 開発中は2や4でも感じがわかると思いあすし、 本番焼きの際にも、容量の問題があるのであまり大きくはできません。 スマホのゲームでライトマップに100MBも食わせるわけには行かないでしょう。 シーンの数だけ焼くわけですし、ステージあたり1024x1024を1枚、 という程度に抑えたい気分でいます。
  • Debug Settings
    • AutoGenerate
      • offにしています。勝手に焼かれなくなるので、ライトを動かしたり、モノを動かしたり した時にはちゃんとライティングがおかしくなり、「焼いている」ということを 忘れないで済みます。勝手に焼かれた方が便利であればonでも良いのでしょう。

Quality Settings

スマホ系のプラットホームではMediumが標準なので、それをいじっておくのが良さそうです。

  • Pixel Light Count
    • 1のままにしておきました。直接光の個数が増えるとシェーダが比例して重くなるので、 1個しかライトは当てない、と割り切ってしまった方が良いかと思います。 炎の近くで明るくなるとか、打撃が当たった所が光るとか、 そういうことはやりたくなるわけですが、加算合成のエフェクトとLightProbe の合わせ技でごまかす方が無難ではないでしょうか。
  • Anti Aliasing
    • ポリゴンの輪郭が綺麗になりますが、下の機種に合わせるならoffでしょう。 スマホの場合元々ドットが小さいので、 ポリゴン境界が多少ジャギッてもそんなに気にならない気がします。 これによる負荷は完全に機種依存なので、どの機種でどう重くなるかが読めない、 というのも厄介なところです。 ただ、「解像度を下げる代わりにアンチエイリアスはOn」という駆け引きも あるかとは思います。
  • Realtime Reflection Probes
    • offのままが良いでしょう。間接鏡面反射のデータをゲーム実行中に生成更新する、 というのは結構大変です。ただ、車ゲームは例外です。自分の車に動く風景が写り こまないとどうにも寂しいので、そういう場合はonにしたくなります。
  • Shadows
    • まずはHard Shadow Onlyで良いでしょう。影の輪郭を柔くする処理は重いのです。 曇りや屋内などで影がクッキリしてると変、という場合には考えないといけませんが、 「そもそもシャドウマップをやめて丸影にする」という思い切った手の方が 良い気もします。なお、Disableにするとかなり軽くなります。
  • Shadow Distance
    • 小さすぎると遠くで影がなくなってしまうので、必要に応じて大きくしましょう。 画面端での歪みを軽減するために、画角(FieldOfView)を小さくして 遠くから写す(望遠に寄せる)場合は、ここが足りなくなりやすいので注意が必要です。 小さいほど影のガビガビ感が減って綺麗になる傾向はありますが、 次の項目をClose Fitにしていればそんなにひどいことにはならないようです。
  • Shadow Projection
    • Close Fitが良さそうです。カメラの状況に合わせてシャドウマップの描画範囲を できるだけ狭くしてくれるので、画質が上がります。 ただし、影の輪郭のガビガビがジラジラと動き続けるので、それが気になるなら Stable Fitにすることになるのでしょうが、品質と負荷にはお気をつけください。 解像度不足を補うためにShadow Resolutionを上げてしまえば、 メモリ量とGPU負荷を押し上げることになります。

測定

雑ですが多少は測定しておきました。京セラS2にて、 フル解像度1280x720での測定です。ミリ秒とfpsを用意しましたので、 どちらでも慣れている方でご覧ください。

設定 フレーム時間(ms) フレーム毎秒(fps)
static焼き+シャドウマップ無効 21 47
static焼き+subtractive
LightProbe/ReflectionProbe無効
26 38
static焼き+subtractive 25 40
static焼き+shadowmask 29 34
static焼き+shadowmask+RealTimeGI 39 25
static不使用 31 32

一応shadowMaskや、RealTimeGIが遅いことの確認ができました。 また、staticにせず焼かないでいるとそれなりに重い、ということも確認できています。 LightProbeやReflectionProbeのGameObjectを無効化したのに 速くならなかったのは、 たぶん環境光の処理がLightProbeやReflectionProbeの処理と 同じ処理だからでしょう。 LightProbeを無効にしても環境光のデータがつっこまれて同じ計算がされる、 ということかと思います。

なお、この過程でスクショを取ったので貼っておきます。

f:id:hirasho0:20190719140538p:plainf:id:hirasho0:20190719140536p:plainf:id:hirasho0:20190719140531p:plainf:id:hirasho0:20190719140534p:plain

順に、Subtractive、Shadowmask、static不使用、Subtractive影なし、です。

Shadowmaskにすると「影の中に影」問題が起こらなくて実に良いですね。 しかも地面が全体に明るく、これは直接光の鏡面反射計算によってその分明るくなっているからです。 その分だけ重くなっていますから、悩むことになるのでしょう。

staticフラグを全部外すと、緑や赤の壁の影響は全然わからなくなってしまいます。 しかも軽くもないという状態です。 しかし影は全部くっきりで一貫性があります。この方が望ましい製品もあるでしょう。 ただ、処理は結構重いです。

そして、影をなくすと結構速くなります。 丸影にしてしまえば、ほぼこの速度でゲームが動くわけです。

しかしそれでもなお60fpsには到達せず、 Standard Shaderを使う限り、この解像度で60fpsを出すのは無理そうだ、 ということがわかります。 そこまで軽くしたければ、そもそも3Dゲームにするな、とか、 照明計算をするな、とか、さらには「シェーダ全部自分で書け」ということになるかと思います。

しかしまあ、実際には解像度を下げれば良いでしょう。1280x720は高すぎるからです。 解像度を下げた分高度な手法を入れる、という方が総合的な質は高くなるかもしれません。

まとめ

簡単に書くつもりが、また長くなってしまいました。

Unityを使えば最新のテクニックをスイッチ一個で有効化できてうれしいわけですが、 最新のテクニックは重いのです。 たぶんUnityがメインターゲットにしているのはPCなので、 スマホ用、とりわけ低性能機種もケアするならそれなりに中身を理解して設定する必要がある ように思えます。

せっかく2010年代後半の技術が山盛りになっているのに、 2000年代中盤にやっていたレベルに落とす設定をして回る、 というのは悲しいですね。 LWRPが標準になる気配はありますが、たぶんLWRPにすると 今回の比でないくらい私のスマホに厳しい結果になると思います。

それにしても60fps出ないのは悔しいですね。 RenderPipelineごと置き換えたい衝動に駆られます。 到底割に合わないのでやらないつもりですが、 もしやってしまったら、たぶんそれも記事にすると思います。