【Unity】広域マップの計算誤差対策
ある夜眠れなくてこの世の深淵について考えていた時に、ふと『オープンワールドゲームを作るとして、計算誤差対策はどうやるんだろう』と思いいたり、作ってみることにした。
なお、完全に思い付いただけの手法をコードに落としてるだけなので、実用性は保証しない。
そもそもUnityでオープンワールドを作ると何が問題なのか
3Dでキャラクターが移動するゲームを作るのであれば、座標管理は必須だ。画面上の描画も、当たり判定も、座標情報がなければ成しえない。
Unityにはデフォルトでこれをサポートする機能が入っていて、どんなオブジェクトでも、Unityではシーン上に配置されればTransformによってオブジェクトの配置座標や回転方向を管理してくれるようになる。これによって開発者は座標管理から解放され、特に面倒くさいことをせずとも、(そのほか様々なサポート機能のおかげもあり)キャラクターの移動処理や当たり判定・描画処理が使える。
さて、そんなうれしいTransformであるが、オープンワールドのような広大なマップを持つゲームを作ろうと思うと直面しそうな問題がある。
floatの計算誤差問題である。
Unityで使われるTransformのPosition、つまり座標管理に利用される変数の型はVector3といって、内部を (float x, float y, float z) の3つの float で構成される構造体である。
floatは日本語で 浮動小数点 と呼ばれる型で、int とは異なり小数点以下の数値が扱え、3Dの座標計算で普通使われる大変ありがたい型なのだが 浮動 という名の通り、精度が良くない。
良くないといっても普通に使う分には全く問題ないので使われているのだが、扱う数値が 1000 あたりを超えてくると少し怪しくなってくる。
その辺の解説には自信がないので詳細はこちら 浮動小数点数の限界を把握する - Qiita を見ていただくとして、つまり、『オープンワールドゲームを作ってると問題が起きそう』ということである。
具体的にはこういうことが起きる
「Floating-point Precision Breakdown」というワールド。
— Dice1900 (@BigVinegar) 2020年3月15日
Unityの仕様上、原点から離れれば離れる程物体の座標の
精度が落ちるのだが、それを身をもって体験できるワールド。ただのUnityの仕様なのだが、自分や周囲の物が次第に崩壊していく様は、SCPじみた怖さがある。#VRchat#VRChat_world紹介 pic.twitter.com/tOcy54itex
これはVRChatにある、floatの計算誤差を楽しむためのワールドらしい。
どう解決するか
前述のような問題点は座標管理に使われているのが float 型であるのが原因なので、 double が使えるなら double を使えば良いのだが、(doubleについては前述 浮動小数点数の限界を把握する - Qiita を参照)UnityのTransformではpositionにdoubleを与えることができないので、これは使えない。
そこで安直ではあるが、原点を動かす手法を考えた。
原点を動かす?
float を使用すると誤算問題が発生することは紹介したが、この誤差問題が大きく問題になってくるのは小数点以上の値が1000辺りを超えて小数点以下の数値の期待できる精度が一定の桁数を下回ってきた場合である。
キャラクターの座標値に float を使っている場合、例えば はじまりの町 を 座標(0, 0, 0) に配置し、 魔王城 を 座標(10000, 0, 0) などに配置すると、ゲームをスタートした直後は問題なくとも、ゲームを進め、魔王城に到着するころには周囲のオブジェクトはガクガクと震え、キャラクターは輪郭を保つことすら難しくなっているだろう。
しかし、ゲームの座標という観点で見るのであれば、これはあくまで はじまりの町 を原点位置に置いてあるから起きている問題なのである。
どういうことかというと、例え はじまりの町 と 魔王城 が 10km 離れた位置に配置されていようと、 魔王城 が 座標(0, 0, 0) はじまりの町 が 座標(-10000,0,0) に配置されているのであれば 魔王城 にいる限りは特別問題は起きない。
もちろん、 魔王城 を原点に配置しただけで問題が解決するわけではない。これだけでは、今度は逆にはじまりの町に戻るとゲームが崩壊してしまう。
ではどうするか。
はじまりの町 の近くにいるときは はじまりの町 を原点に 魔王城 の近くにいるときは 魔王城 を原点に配置すれば良いのだ。
原点を動かす!
さて、簡単に構成を考える。 まずほしい情報は
- 現在のPCの座標
- 原点になりうるオブジェクトの位置
- これはランドマークと呼ぶことにする
あたりだろうか。
これらの情報は Vector3 の形で欲しいが、 Vector3 そのままで持っても仕方がない(通常のTransformをそのまま動かしているのと変わらなくなる)ので、 float の代わりに double で座標を保持する Vector3 を用意する。
今回はここの実装は既に同様の機能を実現されているこちら [Unity] double 精度の Vector3 クラス · GitHub を使わせていただいた
そして実装は以下のようにする
using Imu; using System.Collections.Generic; using UnityEngine; public class OpenWorldOriginManager : MonoBehaviour { [SerializeField] List<Landmark> landmarks = new List<Landmark>(); [SerializeField] Transform character = null; [SerializeField] CharacterMoveController characterMoveController = new CharacterMoveController(); DVector3 realCharacterPosition = DVector3.zero; float lastTime = 0; void Update() { realCharacterPosition = characterMoveController.Move(realCharacterPosition, Time.time - lastTime); SimpleOriginSwitch(); lastTime = Time.time; } // 最も近いランドマークを原点とし、キャラクターやそのほかのランドマークの座標を移動させる void SimpleOriginSwitch() { DVector3 nearest = DVector3.zero; for (int i = 0; i < landmarks.Count; i++) { nearest = Near(realCharacterPosition, nearest, landmarks[i].position); } SetOrigin(nearest); } void SetOrigin(DVector3 center) { for (int i = 0; i < landmarks.Count; i++) { landmarks[i].entity.position = new Vector3( (float)(landmarks[i].position.x - center.x), (float)(landmarks[i].position.y - center.y), (float)(landmarks[i].position.z - center.z)); } character.position = new Vector3( (float)(realCharacterPosition.x - center.x), (float)(realCharacterPosition.y - center.y), (float)(realCharacterPosition.z - center.z)); } DVector3 Near(DVector3 center, DVector3 a, DVector3 b) { return (Distance(center, a) < Distance(center, b)) ? a : b; } double Distance(DVector3 a, DVector3 b) { DVector3 ab = a - b; return System.Math.Sqrt(ab.x * ab.x + ab.y * ab.y + ab.z * ab.z); } }
public class CharacterMoveController { [SerializeField] float moveSpeedPerSec = 0.1f; // Inputを受け取ってnowPosに足し引きするだけの処理 public DVector3 Move(DVector3 nowPos, float timeScale) { Vector3 moves = Vector3.zero; float moveSpeed = moveSpeedPerSec * timeScale; if(Input.GetKey(KeyCode.UpArrow)) { moves.z += 1; } if(Input.GetKey(KeyCode.DownArrow)) { moves.z -= 1; } if(Input.GetKey(KeyCode.RightArrow)) { moves.x += 1; } if(Input.GetKey(KeyCode.LeftArrow)) { moves.x -= 1; } moves = moves.normalized * moveSpeed; return nowPos + moves; } }
実行結果はこんな感じ
画面右上を見てもらえればわかると思うが、座標が一定のラインでカチっと切り替わる。これがよいのか悪いのかわからないが、スムーズに座標切り替え処理する方法も思いついたのでやるだけやった。
コードは以下
[SerializeField, Min(0)] float range = 10; void LerpOriginSwitch() { DVector3 nearest = DVector3.zero; DVector3 secondNear = DVector3.zero; for(int i = 0; i < landmarks.Count; i++) { DVector3 near = Near(realCharacterPosition, nearest, landmarks[i].position); if(!Equals(near, nearest)) { secondNear = nearest; nearest = near; } else if(Equals(nearest, secondNear)) { secondNear = landmarks[i].position; } } double distanceFromCharacterToNearestFromSecond = System.Math.Abs(Distance(nearest, realCharacterPosition) - Distance(secondNear, realCharacterPosition)); DVector3 center; if (distanceFromCharacterToNearestFromSecond > range) { center = nearest; } else { double perInRange = distanceFromCharacterToNearestFromSecond / (range * 2f) + .5f; center = nearest * perInRange + secondNear * (1.0 - perInRange); } SetOrigin(center); }
何をやっているのかというと、現在のキャラクター位置に最も近いランドマークと、2番目に近いランドマークの座標を取得し、その各ランドマークとの距離差が range よりも小さければ lerp 的処理をかけて徐々に原点を ランドマークA から B へと遷移させてる感じです。
実行結果は以下(ランドマークBの座標をx = 950, z = 800 に移動させてある)
画面右上に座標をいくつか載せてあるが、キャラクター座標は最初 x = 300 あたりから始まったのに、途中から減少しだし、最終的には x = -400辺りになっている。
また、ランドマークA, Bともに途中で座標が変わりだし、最終的に A は x=-950, z = -800 に B は x = 0, z = 0 に落ち着いている。
実際に運用した際どっちの方がマシかはわからないが、とりあえず徐々に座標を動かすことには成功した。
感想
実際にオープンワールドゲームなどでどうやっているのかが気になるが、とりあえずそれっぽいものが作れた。
これが実際にはもっと大規模のフィールドになるはずなので、各ランドマークやNPCの座標をDB化して一定範囲より遠いものは動かさないようにしたり、オンラインの場合は別ユーザーの座標を処理したり、揺れ物がある場合はキャラクターの移動量で揺れ量を判定したりしているだろうから、そのあたりの処理も必要そうだ。
この方法にはこういった問題がある・実際にはこういう処理をしているなどあったら教えてください。
では。