Three.jsで三人称視点(TPS)カメラを実装する【キャラクター追従】

キャラクターの後ろからカメラが追いかける三人称視点(TPS)は、FPSより実装の絡みが多くて、「キャラを動かすとカメラが変な方向を向く」「振り向くとカメラが吹っ飛ぶ」あたりで詰まりがちです。私も最初に作ったときは、キャラがカメラに向かって突進してきて画面がキツネで埋まるバグを作りました。

今回は依存ライブラリなしで、TPSの基本形(カメラ基準の移動+滑らかな回頭+遅延追従カメラ)を作ります。デモはWASDで移動、Shiftで走ります。

デモを別タブで開く

構成: モデルは「入れ物」に入れる

まず構造上のコツから。読み込んだモデルを直接動かすのではなく、空のObject3Dを「プレイヤー」として、モデルはその子に入れます

const player = new THREE.Object3D(); // 移動・回転はこっちに対して行う
scene.add(player);

new GLTFLoader().load('./model/fox/Fox.gltf', (gltf) => {
  const model = gltf.scene;

  // モデルの実寸に依存しないよう、バウンディングボックスから縮尺を決める
  const box = new THREE.Box3().setFromObject(model);
  const size = box.getSize(new THREE.Vector3());
  model.scale.setScalar(3.2 / Math.max(size.x, size.y, size.z));

  player.add(model);
});

こうしておくと、移動ロジックはモデルの読み込み完了を待たずに書けますし、モデルの差し替えやサイズ調整がプレイヤーの座標系に影響しなくなります。モデルの読み込み方自体は以前の記事の通りです。

移動はカメラの向き基準で

TPSでは「Wを押したら画面の奥へ進む」のが自然です。つまり移動の基準はキャラクターではなくカメラの向きです。

function updatePlayer(deltaTime) {
  const input = new THREE.Vector3();
  if (keyStates['KeyW']) input.z -= 1;
  if (keyStates['KeyS']) input.z += 1;
  if (keyStates['KeyA']) input.x -= 1;
  if (keyStates['KeyD']) input.x += 1;
  if (input.lengthSq() === 0) return;

  // カメラの向く方角(水平成分)と、その右方向
  const forward = new THREE.Vector3();
  camera.getWorldDirection(forward);
  forward.y = 0;
  forward.normalize();
  const right = new THREE.Vector3().crossVectors(forward, new THREE.Vector3(0, 1, 0));

  // 入力をカメラ基準の移動ベクトルに変換
  const move = forward
    .multiplyScalar(-input.z)      // W(z=-1)で前へ
    .addScaledVector(right, input.x)
    .normalize()
    .multiplyScalar(speed * deltaTime);

  player.position.add(move);
}

getWorldDirection でカメラが向いている方向を取り、y = 0 で水平に潰すのがポイントです。これを忘れると、カメラが見下ろしているときに前進で地面にめり込んでいきます。

ここの座標変換はTPS実装で一番バグりやすい場所です。方向が逆になったら(冒頭の私のように)、-input.z の符号まわりをまず疑ってください。

キャラクターの回頭は「最短の角度差」で

進行方向に player.rotation.y = Math.atan2(move.x, move.z) と即座に向けると、反転時にキャラがパキッと切り替わって安っぽくなります。目標角度へ毎フレーム少しずつ回すと滑らかです。

const targetAngle = Math.atan2(move.x, move.z);

// 角度差を -π〜+π に正規化(これをしないと大回りに回転する)
const diff = ((targetAngle - player.rotation.y + Math.PI * 3) % (Math.PI * 2)) - Math.PI;

player.rotation.y += diff * Math.min(1, deltaTime * 10);

真ん中の1行が肝です。たとえば「350°から10°へ」回るとき、素直に差を取ると-340°回転してしまいます。差を-180°〜+180°に折り畳むことで、常に近い方(この例なら+20°)へ回るようになります。

遅延追従カメラ

カメラは「プレイヤーの背後+上」の位置を目標に、毎フレーム少しずつ近づけます。

const CAMERA_DISTANCE = 5.5;
const CAMERA_HEIGHT = 2.5;

function updateCamera(deltaTime) {
  // プレイヤーの向きの逆方向にオフセット
  const back = new THREE.Vector3(0, 0, -1)
    .applyAxisAngle(new THREE.Vector3(0, 1, 0), player.rotation.y)
    .multiplyScalar(CAMERA_DISTANCE);

  const targetPosition = player.position.clone()
    .add(back)
    .add(new THREE.Vector3(0, CAMERA_HEIGHT, 0));

  // フレームレートに依存しない補間
  camera.position.lerp(targetPosition, 1 - Math.exp(-deltaTime * 3));

  camera.lookAt(player.position.clone().add(new THREE.Vector3(0, 1.2, 0)));
}

補間係数に 1 - Math.exp(-deltaTime * 3) という式を使っているのには理由があります。lerp(target, 0.1) のような固定値だと、60Hzと144Hzのモニターで追従の速さが変わってしまいます。指数減衰の式にしておくと、どのフレームレートでも同じ挙動になります(3 が追従の強さで、大きいほどキビキビ追う)。

この「遅れてついてくる」感じが、実はTPSの気持ちよさの大部分です。試しに lerp をやめて直接 copy すると、正確だけど酔いそうな画面になります。

移動速度とアニメーションを連動させる

仕上げに、止まっているときはidle、歩けばwalk、Shift押下でrunを再生します。切り替えはglTF記事で書いたクロスフェードそのままです。

function switchAnimation(next) {
  if (!next || next === currentAction) return;
  next.reset().fadeIn(0.25).play();
  currentAction.fadeOut(0.25);
  currentAction = next;
}

// updatePlayer内で
switchAnimation(input.lengthSq() > 0 ? (running ? run : walk) : idle);

ここから先の発展

  • マウスでカメラを回す — 背後固定ではなく、マウスでカメラを軌道回転させたい場合は、OrbitControlsの target を毎フレームプレイヤー位置に更新する構成が手軽です
  • カメラの壁抜け対策 — プレイヤーとカメラの間にレイを飛ばし、障害物に当たったらカメラをその手前まで詰めるのが定番です
  • 地形対応Raycaster接地と組み合わせれば起伏のある地面のTPSになります

まとめ

  • モデルは空のObject3Dに入れて、移動・回転は親に対して行う
  • 移動はカメラの向き基準(getWorldDirection + 水平化)
  • 回頭は角度差を-π〜+πに正規化してから補間
  • カメラ追従は 1 - exp(-dt * k) でフレームレート非依存のlerp