はじめに
Unity 3Dでのジャンプの長さが変わってもちゃんと動くアニメーション対応のジャンプ処理の記事を書こうと思ったのですが、その前にUnity 3Dでのジャンプ処理自体を取り扱っている記事が少なそうなので、基礎的なところを先に書いていきます。
(Qiitaに少なそうなだけでggればいろいろ出てくると思いますが)
(2018/09/16追記:色々って言うわりにUpdateとFixedUpdateの説明しか書いてないので記事名を変更)
(最初の記事名は「Unity 3Dで色々考慮したジャンプ(基礎)」でした)
確認した環境
- Windows 10
- Unity 2018.2.4f1
- プレイヤーキャラクターに Unity Technologies Japanオリジナルキャラクター Unityちゃん を利用
ベース環境作成
スクリプトに入る前に、プロジェクトをゼロから作っておきます。
プロジェクト作成
ご利用のUnityで新規プロジェクトを作成してください。
タイトルは何でも良いのですが、筆者は今回は JumpTest と付けました。
床作成
とりあえずの床として Plane を作成します。名前はFloorとします。
Unityちゃん導入
Unityちゃんモデルの入手方法が、公式からのダウンロードとアセットストアからのものと2種類あるんですが、公式からダウンロードしてきてimportしたほうがバージョンが新しいようです。
アセットストアのものはバージョン 1.1
公式は1.2.1です。
Modelsディレクトリの中の unitychan をヒエラルキーに追加します。
プレイヤーキャラクターのGameObjectに各種コンポーネントを設定
Animator
unitychan のGameObjectにはAnimatorコンポーネントが1つだけアタッチされていますが、今回は使わないので削除します。
Capsule Collier
今の状態では接触判定がありませんのでCapsule Collierを追加します。
何かコライダーがあれば接触判定は可能ですが、人型のオブジェクトには形状的にCapsule Collierが適しているので、これをアタッチします。
(Capsule Colliderの端の球面のせいで接触判定に支障が出る場合は、Box Colliderでも十分代用が効きます。筆者は壁際に落ちてる細長いもの(剣とか棒とか)に接触出来なくて拾えなかったことがあります)
アタッチしたままではUnityちゃんの足元にだけ判定が出てしまうので、設定を行います。
InspectorのCenterでコライダーの中心をUnityちゃんの中心にあわせて、Heightでコライダーの高さをUnityちゃんの身長にあわせ、Radiusで半径を調整します。
Rigidbody
物理法則に則った動作をしてもらうために、Rigidbodyも追加します。
そのままだとキャラクターがコケて倒れてしまったりなどするので、Constrainsの中にあるFreeze RotationでxとzのチェックをONし、倒れてしまう向きの回転を停止します。
TransformのRotationのところをドラッグで数値を変更すると、どの値がどの向きの回転なのか分かります。yは横方向の回転のみなので、固定しなくても大丈夫です。
スクリプト作成
これで基本の設定は完了したので、スクリプトを作成しますが、ここでまた考えないといけないことがあります。
Update()とFixedUpdate()の違いについてで、皆さんご存知のことと思いますが、Update()とFixedUpdate()は実行されるタイミングとするべき処理が違います。
UnityではUpdate()のFPSはデフォルトではディスプレイの垂直同期に同期します。
一般的なディスプレイでは60Hzですが、VRゴーグルでは90Hzが多いようです。
ゲーミングディスプレイの場合は120Hzや144Hzの場合もあり、現行機種で最も速いものは240Hzのものが出ています。数年すると300Hzが出てくるのではとも言われています。
(Project Settings -> Quality -> V Sync Count)
FixedUpdate()はUpdate()とは独立したタイマーで固定時間毎に実行され、Unityで設定した通りのFPSで呼ばれます。デフォルトでは50fpsです。
(Project Settings -> Time -> Fixed Timestep)
そして、Update()で入力処理を行わなければならず、FixedUpdate()で物理演算の処理を行う必要があることもご存知のことと思います。
スクリプトはこの2点について十分に留意して作成する必要があります。
垂直同期との同期をOFFにして目標FPSを別で設定する方法もありますが、参考資料にリンクを置いておくのでそちらのほうが良い方は各自ご確認ください。
タイミング概念図
上図はUpdate()が60fps、FixedUpdate()が50fpsと仮定して、数回に1回Update()が2回くるタイミングがある図です。
Update()のフレームレートが上がると、もちろんUpdate()の介入回数が増えます。
120Hzまたは144Hzの場合は、だいたいUpdate()2回にFixedUpdate()が1回あり、時々Update()が3回ある、という形になります。
240Hzでは4回に1回になり、時々5回になります。
極端な例ですが、ディスプレイと設定次第で既に現実に起こり得るものです。
Update()でジャンプ入力が複数フレーム続き、次のFixedUpdate()のタイミングでジャンプ入力が終わってしまっていても1回だけジャンプ処理される、という処理にしなければなりません。
ジャンプ処理の設計
以上の注意点とタイミング図からジャンプの設計は以下の通りとします。
- ジャンプ入力は、次のFixedUpdate()まで記憶する
- ジャンプ入力の記憶は、ジャンプ入力が複数回あっても一つだけ記憶し、FixedUpdate()が来るまではジャンプ入力が終了しても記憶したままとする
- ジャンプ入力の記憶がある間は再ジャンプ入力は受け付けない
- ジャンプ処理が終わって着地したタイミングでジャンプ入力の記憶を消去する
- FixedUpdate()ではジャンプ入力の記憶を見てジャンプ処理を開始する
- ただし、このジャンプ入力の記憶は着地するまでずっとONのままなので、
FixedUpdate()内での再ジャンプ処理を禁止するための内部用の別のフラグが必要 - 内部用のフラグはFixedUpdate()内でジャンプ処理開始時にONし、着地時にOFFする
だいたいこんな感じになっていれば、多重にジャンプ処理が行われてしまうことがなくなるはずです。
スクリプト
using UnityEngine;
using UnityEngine.Events;
public class PlayerController : MonoBehaviour
{
///<summary>
/// ジャンプ処理に使用するRigidbody
///</summary>
private Rigidbody _rigidBody;
///<summary>
/// ジャンプ入力フラグ
/// ジャンプ入力が一度でもあったらON、着地したらOFF
///</summary>
private bool _jumpInput = false;
///<summary>
/// ジャンプ処理中フラグ
/// ジャンプ処理が開始されたらON、着地したらOFF
///</summary>
private bool _isJumping = false;
///<summary>
/// Start()
///</summary>
private void Start()
{
_rigidBody = GetComponent<Rigidbody>();
}
///<summary>
/// Update()
///</summary>
private void Update()
{
CheckGroundDistance(() => {
_jumpInput = false;
_isJumping = false;
});
// 既にジャンプ入力が行われていたら、ジャンプ入力チェックを飛ばす
if (_jumpInput || JumpInput()) _jumpInput = true;
}
///<summary>
/// FixedUpdate()
///</summary>
private void FixedUpdate()
{
if (_jumpInput) {
if (!_isJumping) {
_isJumping = true;
DoJump();
}
}
}
///<summary>
/// ジャンプ入力チェック
///</summary>
private bool JumpInput()
{
// ジャンプ最速入力のテスト用にGetButton
if (Input.GetButton("Jump")) return true; // ジャンプキー押しっぱなしで連続ジャンプ
//if (Input.GetButtonDown("Jump")) return true; // ジャンプキーが押された時だけジャンプにする時はこっち
// または、 if (Input.GetKeyUp(KeyCode.Space)) return true; とかでも可
return false;
}
///<summary>
/// ジャンプの強さ
///</summary>
[SerializeField] private float jumpPower = 5f;
//private const float jumpPower = 5f;
///<summary>
/// ジャンプのための上方向への加圧
///</summary>
private void DoJump()
{
_rigidBody.AddForce(Vector3.up * jumpPower, ForceMode.Impulse);
}
///<summary>
/// 接地してから何フレーム経過したか
/// 接地してない間は常にゼロとする
///</summary>
private int _isGround = 0;
///<summary>
/// 接地してない間、何フレーム経過したか
/// 接地している間は常にゼロとする
///</summary>
private int _notGround = 0;
///<summary>
/// このフレーム数分接地していたらor接地していなかったら
/// 状態が変わったと認識する(ジャンプ開始したor着地した)
/// 接地してからキャラの状態が安定するまでに数フレーム用するため、
/// キャラが安定する前に再ジャンプ入力を受け付けてしまうとバグる(ジャンプ出来なくなる)
/// 筆者PCでは 3 で安定するが、安全をとって今回は 5 とした
///</summary>
private const int _isGroundStateChange = 5;
///<summary>
/// プレイヤーと地面の間の距離
/// IsGround()が呼ばれるたびに更新される
///</summary>
[SerializeField] private float _groundDistance = 0f;
///<summary>
/// _groundDistanceがこの値以下の場合接地していると判定する
///</summary>
private const float _groundDistanceLimit = 0.01f;
///<summary>
/// 判定元の原点が地面に極端に近いとrayがヒットしない場合があるので、
/// オフセットを設けて確実にヒットするようにする
///</summary>
private Vector3 _raycastOffset = new Vector3(0f, 0.005f, 0f);
///<summary>
/// プレイヤーキャラから下向きに地面判定のrayを飛ばす時の上限距離
/// ゲーム中でプレイヤーキャラと地面が最も離れると考えられる場面の距離に、
/// マージンを多少付けた値にしておくのが良
/// Mathf.Infinityを指定すれば無制限も可能だが重くなる可能性があるかも?
///</summary>
private const float _raycastSearchDistance = 100f;
///<summary>
/// 接地判定
///</summary>
private void CheckGroundDistance(UnityAction landingAction = null, UnityAction takeOffAction = null)
{
RaycastHit hit;
var layerMask = LayerMask.GetMask("Ground");
// プレイヤーの位置から下向きにRaycast
// レイヤーマスクでGroundを設定しているので、
// 地面のGameObjectにGroundのレイヤーを設定しておけば、
// Groundのレイヤーを持つGameObjectで一番近いものが一つだけヒットする
var isGroundHit = Physics.Raycast(
transform.position + _raycastOffset,
transform.TransformDirection(Vector3.down),
out hit,
_raycastSearchDistance,
layerMask
);
if (isGroundHit) {
_groundDistance = hit.distance;
} else {
// ヒットしなかった場合はキャラの下方に地面が存在しないものとして扱う
_groundDistance = float.MaxValue;
}
// 地面とキャラの距離は環境によって様々で
// 完全にゼロにはならない時もあるため、
// ジャンプしていない時の値に多少のマージンをのせた
// 一定値以下を接地と判定する
// 通常あり得ないと思われるが、オーバーフローされると再度アクションが実行されてしまうので、越えたところで止める
if (_groundDistance < _groundDistanceLimit) {
if (_isGround <= _isGroundStateChange) {
_isGround += 1;
_notGround = 0;
}
} else {
if (_notGround <= _isGroundStateChange) {
_isGround = 0;
_notGround += 1;
}
}
// 接地後またはジャンプ後、特定フレーム分状態の変化が無ければ、
// 状態が安定したものとして接地処理またはジャンプ処理を行う
if (_isGroundStateChange == _isGround && _notGround == 0) {
if (landingAction != null) landingAction();
} else
if (_isGroundStateChange == _notGround && _isGround == 0) {
if (takeOffAction != null) takeOffAction();
}
}
}
(2018/09/16追記:クラス名がPlayerController2だったのをPlayerControllerに修正)
UniRx入れてReactivePropertyとかObservable使うともっとすっきり書けると思いますが、まずは分かりやすく素のUnityで動くスクリプトです。
Start()、Update()、FixedUpdate()を見れば大まかな処理は分かると思いますが、
雑に一覧にした設計に従って、ジャンプ入力を受け付けて保持、FixedUpdateでジャンプ処理を行っています。
ジャンプ処理は Rigidbody.AddForce()
を使っていて、ForceMode.Impulse
で質量を考慮した瞬間的な加圧によってジャンプさせています。
着地の判定は Physics.Raycast
で地面との距離をUpdate()で毎フレームチェックしていて、地面との距離が一定値以下の状態が指定フレーム続いたら着地したと判定するようになっています。
これはUnityの物理演算がブレを許容する構造になっているからで、地面と接触した瞬間を拾うようにすると、ジャンプ入力がそのフレームに同時に入った場合にジャンプが出来なかったり、ジャンプの高さが変わってしまったりする為です。
筆者環境では60fpsで3フレーム置けば安定して動作するのを確認済みですが、他の環境(ロースペックPCとかfpsがもっと高いとか)ではもしかしたらもっとかかるかもしれないので、一応マージンを取って5フレーム待つようにしてあります。
またこのスクリプトでは、最後の CheckGroundDistance
メソッドでGroundレイヤーのオブジェクトを地面として認識しているので、
床として作ったPlaneをGroundレイヤーとして設定します。
動かす
ジャンプに使うスペースキーを押しっぱなしにしたり、定間隔でぽちぽち押してみたり
色々試しましたが、ジャンプできなくなったり多重ジャンプしたりのような兆候は今の所見られません。
UniRx
まだあんまり書き慣れてないので粗末なものですが。
using UnityEngine;
using UnityEngine.Events;
using UniRx;
using UniRx.Triggers;
public class PlayerController3 : MonoBehaviour
{
///<summary>
/// ジャンプの強さ
///</summary>
[SerializeField] private float jumpPower = 5f;
///<summary>
/// Start()
///</summary>
private void Start()
{
var rigidBody = GetComponent<Rigidbody>();
var jumpInput = false;
var isJumping = false;
/*
//入力ストリーム
var inputStream = this.UpdateAsObservable()
.Select(_ => {
//if (jumpInput || JumpInput()) jumpInput = true;
if (jumpInput || Input.GetButton("Jump")) jumpInput = true;
return jumpInput;
});
// 入力ストリームを通すと処理が遅れるようでリアルタイムなやり取りが難しかったので
// 今回はパス
*/
//FixedUpdateを主軸にし、そこにinputStreamを合成する
this.FixedUpdateAsObservable()
//.WithLatestFrom(inputStream, (_, jump_input) => jump_input)
.Subscribe(x =>
{
if (jumpInput && !isJumping) {
isJumping = true;
Debug.Log("jumping : " + (Vector3.up * jumpPower).ToString());
rigidBody.AddForce(Vector3.up * jumpPower, ForceMode.Impulse);
}
});
this.UpdateAsObservable().Subscribe(x => {
if (jumpInput || Input.GetButton("Jump")) jumpInput = true;
CheckGroundDistance();
});
// 接地後またはジャンプ後、特定フレーム分状態の変化が無ければ、
// 状態が安定したものとして接地処理またはジャンプ処理を行う
_is_ground.Where(value => value == _isGroundStateChange).Subscribe(x => {
jumpInput = false;
isJumping = false;
});
/*
_not_ground.Where(v => v == _isGroundStateChange).Subscribe(x => {
jumpInput = false;
isJumping = false;
});
*/
}
///<summary>
/// 接地してから何フレーム経過したか
/// 接地してない間は常にゼロとする
///</summary>
private ReactiveProperty<int> _is_ground = new ReactiveProperty<int>();
///<summary>
/// 接地してない間、何フレーム経過したか
/// 接地している間は常にゼロとする
///</summary>
private ReactiveProperty<int> _not_ground = new ReactiveProperty<int>();
///<summary>
/// このフレーム数分接地していたらor接地していなかったら
/// 状態が変わったと認識する(ジャンプ開始したor着地した)
/// 接地してからキャラの状態が安定するまでに数フレーム用するため、
/// キャラが安定する前に再ジャンプ入力を受け付けてしまうとバグる(ジャンプ出来なくなる)
/// 筆者PCでは 3 で安定するが、安全をとって今回は 5 とした
///</summary>
private int _isGroundStateChange = 5;
///<summary>
/// プレイヤーと地面の間の距離
/// IsGround()が呼ばれるたびに更新される
///</summary>
[SerializeField] private float _groundDistance = 0f;
///<summary>
/// _groundDistanceがこの値以下の場合接地していると判定する
///</summary>
private float _groundDistanceLimit = 0.01f;
///<summary>
/// 判定元の原点が地面に極端に近いとrayがヒットしない場合があるので、
/// オフセットを設けて確実にヒットするようにする
///</summary>
private Vector3 _raycastOffset = new Vector3(0f, 0.005f, 0f);
///<summary>
/// プレイヤーキャラから下向きに地面判定のrayを飛ばす時の上限距離
/// ゲーム中でプレイヤーキャラと地面が最も離れると考えられる場面の距離に、
/// マージンを多少付けた値にしておくのが良
/// Mathf.Infinityを指定すれば無制限も可能だが重くなる可能性があるかも?
///</summary>
private float _raycastSearchDistance = 100f;
///<summary>
/// 接地判定
///</summary>
private void CheckGroundDistance()
{
RaycastHit hit;
var layerMask = LayerMask.GetMask("Ground");
// プレイヤーの位置から下向きにRaycast
// レイヤーマスクでGroundを設定しているので、
// 地面のGameObjectにGroundのレイヤーを設定しておけば、
// Groundのレイヤーを持つGameObjectで一番近いものが一つだけヒットする
var isGroundHit = Physics.Raycast(
transform.position + _raycastOffset,
transform.TransformDirection(Vector3.down),
out hit,
_raycastSearchDistance,
layerMask
);
if (isGroundHit) {
_groundDistance = hit.distance;
} else {
// ヒットしなかった場合はキャラの下方に地面が存在しないものとして扱う
_groundDistance = float.MaxValue;
}
// 地面とキャラの距離は環境によって様々で
// 完全にゼロにはならない時もあるため、
// ジャンプしていない時の値に多少のマージンをのせた
// 一定値以下を接地と判定する
// 通常あり得ないと思われるが、オーバーフローされると再度アクションが実行されてしまうので、越えたところで止める
if (_groundDistance < _groundDistanceLimit) {
if (_is_ground.Value <= _isGroundStateChange) {
_is_ground.Value += 1;
_not_ground.Value = 0;
}
} else {
if (_not_ground.Value <= _isGroundStateChange) {
_is_ground.Value = 0;
_not_ground.Value += 1;
}
}
}
}
AsObservableしただけですね・・・
もっとローカルにごりごり押し込んで高速化したり簡略化出来ると思いますが、これで安定動作しているので今回はこの辺で。
参考資料
Unityゲーム開発所 - UnityでFPSを設定する方法
Unityでゲームを作った際に「カクカクしている」と言われないためのTimeSettings.FixedTimestep講座 - 野生のはてなブログ
【UniRx】Update()タイミングのイベントをFixedUpdate()のタイミングに変換する - Qiita
とりすーぷさんいつもお世話になっております。