キャラクターコントローラが滑っているように感じる理由
プレイヤーがキャラクターコントローラが滑っていると感じるとき、ブレーキ、地面検知、斜面処理、補間という4つの別々のバグを指しています。
「滑っている」と感じることの実際の意味
昨年、サードパーソンプロトタイプで6人のプレイテストを実施しました。全員が同じことを言いました:キャラクターコントローラが滑っているように感じる、と。詳細は語れず、最初の2日はカメラのスムージングやアニメーションブレンド時間を調整するなど、間違った箇所を探していました。
問題は240 FPSでフレームごとにキャプチャを確認したときに現れました。スティックを離した後もキャラクターはさらに11フレーム、約180 ms(60 Hz)動き続けました。斜面では水平速度は保たれましたが、変化する地面法線が体を前方に押し出しました。20 cm の段差ではcapsuleが引っかかり、一瞬停止しました。
したがって「滑っている」は単一のバグではありません。減速カーブ、地面チェック、斜面処理、物理と描画の同期がそれぞれ壊れており、4つすべてが同じ不満として表れました。本稿ではそれらを一つずつ解決した手順を解説します。
Rigidbody と kinematic の動きはどちらか
最初のバージョンは古典的なRigidbody と AddForce の組み合わせでした。理論上は正しく見えます:エンジンがシミュレートし、力を加える。実際には物理マテリアルの摩擦をゼロにするとすべての斜面で滑り落ち、摩擦を有効にすると壁に触れた瞬間に速度が失われます。床と壁の両方に適用できる中間的な値は存在しません。
結局は kinematic 動きを採用しました。Rigidbody は残りますが isKinematic がオンになっており、速度を自前で積分し、各物理ステップでカプセルキャストでスイープします。Unity の CharacterController も試しましたが、ステップオフセットとスロープリミット以外に公開されている情報がなく、デペンテレーションの挙動が見えず、斜面を下るときに独自の判断を行います。
ルールはシンプルになりました。衝突クエリは物理エンジンに任せ、速度の決定は自分で行う。CharacterMotor クラスは約300行で、すべての移動数値が一箇所に集約されています。コストはノックバックや移動プラットフォームの乗り移り処理を手動で書くことですが、感触調整にかかる時間はそれ以上に削減できます。
最も目立つ犠牲は動的ボディとの相互作用です。キャラクターは自動的に箱を押すことがなくなります。エンジンが質量として認識しなくなるからです。スイープが動的Rigidbodyにヒットしたときは、相対速度と質量比でスケールしたインパルスを適用します。この15行のコードで、プレイヤーが実際に目にする失われた挙動の一部を取り戻しました。
カプセルキャストでの地面検知と斜面処理
単一の下向きレイはエッジで失敗します。キャラクターの中心がプラットフォームの端から3〜4 cm 外れるとレイが外れ、体は1フレームだけ空中と判定され、次のフレームで再び地上になります。その代わりに、体と同じ半径のCapsuleCastを使用します:半径0.15 m、下向きに0.02 m のスキン許容を加えてキャストします。
歩行可能判定はヒット法線の垂直成分を読み取ります:hit.normal.y >= 0.5736f、これは55度の余弦です。限界付近でのチャタリングを防ぐためにヒステリシスを導入し、55度で地上状態に入り、58度まで維持します。この単一比較で急勾配のエッジでの地上/空中振動が止まりました。
斜面では正しい平面への速度投影が本質的です。生の水平入力を適用すると下り坂で加速し、上り坂で遅くなります。ProjectOnPlaneで入力ベクトルを地面法線に対して平面に投影すれば、傾斜に関係なく歩行速度が一定になります。また、下り坂では最大0.3 mまで地面にスナップさせます。これがないと、すべての下り遷移が小さなジャンプになってしまいます。
単一のキャストは単一のヒットを返し、二つの表面が交わる場所ではフレームごとに法線が反転します。8結果バッファを持つCapsuleCastNonAllocに切り替えて最も急な歩行可能法線を選択すれば、この不安定さは解消されました。クエリを専用のレイヤーマスクに分離することも有効で、トリガーやダメージボリュームを除外するだけでキャストコストが約3分の1に減ります。
ステップオフセットと壁沿いのスライディング
ステップ登攀は3回のスイープで行います:ステップ高さ(0.35 m)だけ上に、移動分だけ前方へ、そして下に戻ります。3回すべてがクリアで、下向きスイープが歩行可能な法線に着地すればボディをそこに移動させます;それ以外はキャンセルして表面を壁として扱います。この3回の余分なキャストは、測定した1ステップあたり0.28 msのモータコストのうち約0.06 msを占めています。
壁スライディングは衝突後スライドループそのものです。ヒットしたら残りの動きを接触平面に射影し、再度スイープします。内部コーナーでは1回の射影だけでは足りないため、最大4回の反復を許可し、4回目でもヒットした場合は残りの動きを破棄します。反復回数が無制限だったビルドでは、狭いコーナーでフレーム時間が4 msに急上昇しました。
どれだけスイープを慎重に行っても、ボディは最終的にジオメトリに入り込んでしまいます。特に移動プラットフォームに押されると顕著です。各物理ステップの終わりにPhysics.ComputePenetrationでオーバーラップを測定し、1ステップあたり最大0.2 mまで押し出します。この上限を設けなかったとき、キャラクターが壁を通り抜けてしまいました:シーン内のメッシュコライダーが負のスケールを持っていたため、測定されたオーバーラップは3 mを超えていました。
ステップスイープには2つのガードがあります。1つ目は地上にいる間だけ実行されることです。空中で有効にしていたら、キャラクターが衝突した壁を自ら登ってしまいました。2つ目は上向きスイープが天井に当たった場合、ステップ全体をキャンセルすることです。そうでなければ、狭い通路でボディが天井に一瞬突き刺さります。
加速、ブレーキ、空中制御
ブレーキはスライディング感覚の最も直接的な原因です。Lerpで速度を目標に引き寄せる代わりに、別々のレートを保持します:地上加速45 m/s²、ブレーキ60 m/s²、空中12 m/s²です。歩行速度6.2 m/sでは、リリース後約100 msで完全停止し、テスターが不満を訴えた180 msの約半分です。
ブレーキが加速より高いのは意図的です。対称にするとキャラクターが反応遅く感じ、ブレーキを極端に高くするとロボット的になります。1.3〜1.5の比率が私のプロジェクト全体で一貫して良好でした。デザイナー向けにAnimationCurveで公開しようとしましたが、2つの数値以上の効果は得られず、デバッグが難しくなりました。
空中ではルールが変わります。慣性を保持し、ブレーキはかけず、低い加速レートで入力を加え、水平速度を地上最大値にクランプします。プレイヤーは空中で方向転換できますが、ジャンプで速度を上げることはできません。クランプを忘れたビルドでは、テスターがジャンプとスプリントのコンボで意図速度の1.4倍に達し、レベル境界を直進で超えてしまいました。
入力側には2つの設定が残ります。アナログデッドゾーンを0.15でカットし、残りの範囲を0〜1に再マッピングします。そうしないと遅い歩行が不可能になります。また、向きと移動方向を分離します:ボディは秒間720度回転し、速度ベクトルは即座に変化します。回転を速度より先に処理すると、急激な方向転換が遅延して感じられるためです。
固定タイムステップ、補間と順序
モータは50 Hzの固定ステップで動作し、ディスプレイは144 Hzでリフレッシュします。物理ステップで位置を書き込むと、いくつかのレンダーフレームで同じ姿勢が二度表示されます。その結果生じる微細なジッターが「カクつく」と感じられ、ほとんどの人はフレームドロップと勘違いしてGPUプロファイルを調べに行きます。
解決策は、前回と現在の物理姿勢を保持し、残り時間の比率でUpdate内で補間することです。ビジュアルルート(メッシュとカメラターゲット)は補間姿勢に乗り、コリジョンカプセルは物理姿勢のままです。LateUpdateで同じ補間姿勢にカメラをバインドすることも重要です:1フレーム遅れた読み取りは、キャラクターがカメラの前で泳いでいるように見えます。
固定ステップの第2の利点は再現性です。移動は常に同じステップ数を通過するため、フレーム時間が変動しても同じ入力で同じ経路が得られます。これがないと、ジャンプ距離は60 Hzと144 Hzで約20 cm差が出ました。長いフレームが来たときは、蓄積時間を最大4ステップに制限します。さもなければ、ロードハッチ後にシミュレーションが追いつきません。
同じ不満を聞くなら、次の順序で作業してください:まず停止時間を測定・修正し、次に地面スナップ、ステップスイープ、最後に補間を追加します。各段階でカプセルキャストと地面法線を画面に描画します。この可視化は半日で書き上げ、次の3週間で全バグを一目で確認できました。「滑っているように感じる」は曖昧な文ではなく、まだ測定していない4つの数値の合計です。