【Cocos Creator 3.8】LookAhead(先読みカメラ)の実装:アタッチするだけで「進行方向の少し先を映す」カメラ挙動を実現する汎用スクリプト
本記事では、プレイヤーなどのターゲットの移動方向(velocity)に応じて、カメラ位置を少し前にずらしてくれる「先読みカメラ」コンポーネント LookAhead を実装します。
このコンポーネントをカメラノードにアタッチし、ターゲットノードをインスペクタで指定するだけで、「進行方向の少し先を映す」気持ちのよいカメラ追従が実現できます。外部の GameManager やシングルトンには一切依存せず、スクリプト単体で完結するように設計します。
コンポーネントの設計方針
要件整理
- カメラノードにアタッチして使用する。
- ターゲットノード(通常はプレイヤー)をインスペクタで指定する。
- ターゲットのワールド座標の変化量から「速度(velocity)」を推定し、その方向にカメラをオフセットする。
- オフセット量は「速度 × 係数」で計算し、最大オフセット量を制限できるようにする。
- カメラの追従・オフセットはスムージング(補間)して、急なガクツキを抑える。
- 2D/3D どちらでも使えるよう、基本は「同じ Z を維持して X/Y だけ追従」する形にする。
- 他のノードやスクリプトには依存しない(ターゲットは
@propertyで指定)。
インスペクタで設定可能なプロパティ
以下のようなプロパティを用意します。
target: Node
先読みカメラが追従するターゲットノード。通常はプレイヤー。
必須。未設定の場合は警告ログを出し、動作を停止します。positionLerpFactor: number(0〜1)
ターゲット+先読み位置に対する「カメラ位置」の追従速度。
0 に近いほどゆっくり追従、1 に近いほど即座に追従。
例:0.15〜0.25程度が自然なことが多いです。velocityLookAheadFactor: number
速度ベクトルをどの程度オフセットに反映するかの係数。
単位は「1フレームあたりの移動量に対するオフセット量」。
例:0.3〜0.7程度から調整。maxLookAheadDistance: number
先読みオフセットの最大距離(ワールド座標での長さ)。
速く動いても、これ以上先読みしないように制限します。
例: 横スクロールなら3〜8程度(ゲームのスケールに依存)。lookAheadSmoothing: number(0〜1)
オフセットベクトル自体をどれだけスムージングするか。
0 に近いほどオフセット変化がなめらか(遅い)、1 で即座に変化。
カメラの遅延を減らしたい場合は0.3〜0.6程度がおすすめ。enableVertical: boolean
垂直方向(Y)にも先読みを適用するか。
横スクロールで上下にはあまり動かさない場合はfalse推奨。enableHorizontal: boolean
水平方向(X)に先読みを適用するか。通常はtrue。useLocalSpace: boolean
カメラ位置の更新をローカル座標で行うかどうか。false: ワールド座標で追従(親の影響を受けにくい・素直)。true: 親ノードのローカル空間で追従(パララックス構成などで便利)。
debugDrawGizmos: boolean
シーンビュー上でターゲット位置と先読み位置を簡易表示するデバッグ用フラグ。
実行時にdebugログと簡易ギズモを描画します(パフォーマンスに大きな影響はありませんが、本番ではfalse推奨)。
速度は Rigidbody などに依存せず、「前フレームとの差分」から自前で計算することで、外部コンポーネントへの依存を避けます。
TypeScriptコードの実装
以下が完成した LookAhead.ts の全コードです。
import { _decorator, Component, Node, Vec3, math, warn, director, Gizmo, game } from 'cc';
const { ccclass, property } = _decorator;
/**
* LookAhead(先読みカメラ)
* ターゲットの移動方向(速度)に応じて、カメラ位置を少し前にずらす汎用コンポーネント。
* 外部スクリプトや特定のコンポーネントには依存せず、ターゲットノードのみを参照します。
*/
@ccclass('LookAhead')
export class LookAhead extends Component {
@property({
type: Node,
tooltip: 'カメラが追従するターゲットノード(通常はプレイヤー)。\n未設定の場合、このコンポーネントは動作しません。'
})
public target: Node | null = null;
@property({
tooltip: 'カメラの追従スピード(0〜1)。\n0に近いほどゆっくり追従し、1に近いほど即座にターゲット+先読み位置へ移動します。'
})
public positionLerpFactor: number = 0.2;
@property({
tooltip: 'ターゲットの速度ベクトルに掛ける係数。\n値を大きくすると、進行方向の先をより大きく映すようになります。'
})
public velocityLookAheadFactor: number = 0.5;
@property({
tooltip: '先読みオフセットの最大距離。\nターゲットがどれだけ速く動いても、この距離を超えて先読みしません。'
})
public maxLookAheadDistance: number = 5.0;
@property({
tooltip: '先読みオフセットベクトル自体のスムージング(0〜1)。\n0に近いほど変化がなめらかになり、1で即座に変化します。'
})
public lookAheadSmoothing: number = 0.4;
@property({
tooltip: '水平方向(X軸)に先読みを適用するかどうか。'
})
public enableHorizontal: boolean = true;
@property({
tooltip: '垂直方向(Y軸)に先読みを適用するかどうか。'
})
public enableVertical: boolean = true;
@property({
tooltip: 'true の場合、カメラ位置をローカル座標で更新します。\nfalse の場合、ワールド座標で更新します(通常はこちらで問題ありません)。'
})
public useLocalSpace: boolean = false;
@property({
tooltip: 'デバッグ用の簡易表示を有効にします(シーンビュー上でターゲット位置と先読み位置を表示)。\n本番ビルドでは通常オフにします。'
})
public debugDrawGizmos: boolean = false;
// 内部状態
private _lastTargetPos: Vec3 | null = null; // 前フレームのターゲット位置(ワールド)
private _currentLookAhead: Vec3 = new Vec3(); // 現在の先読みオフセット(ワールド)
private _tempVec: Vec3 = new Vec3();
onLoad() {
// 必要条件のチェック(防御的実装)
if (!this.target) {
warn('[LookAhead] target が設定されていません。このコンポーネントは動作しません。');
}
// 初期位置の記録
if (this.target) {
this._lastTargetPos = new Vec3();
this.target.getWorldPosition(this._lastTargetPos);
}
// 係数のクランプ
this.positionLerpFactor = math.clamp01(this.positionLerpFactor);
this.lookAheadSmoothing = math.clamp01(this.lookAheadSmoothing);
}
start() {
// ゲーム開始時にターゲット位置をもう一度初期化
if (this.target) {
if (!this._lastTargetPos) {
this._lastTargetPos = new Vec3();
}
this.target.getWorldPosition(this._lastTargetPos);
}
}
update(deltaTime: number) {
if (!this.target) {
return;
}
// ターゲットの現在位置(ワールド)を取得
const currentTargetPos = this._tempVec;
this.target.getWorldPosition(currentTargetPos);
// 前フレームの位置が未設定ならここで初期化
if (!this._lastTargetPos) {
this._lastTargetPos = new Vec3(currentTargetPos.x, currentTargetPos.y, currentTargetPos.z);
}
// 速度ベクトル(フレーム間の差分)を計算
const velocity = new Vec3(
currentTargetPos.x - this._lastTargetPos.x,
currentTargetPos.y - this._lastTargetPos.y,
currentTargetPos.z - this._lastTargetPos.z,
);
// 不要な軸をゼロにする
if (!this.enableHorizontal) {
velocity.x = 0;
}
if (!this.enableVertical) {
velocity.y = 0;
}
// Z方向は基本的に先読みしない(カメラの奥行きは維持したいケースが多いため)
velocity.z = 0;
// 先読みオフセットの目標値を計算: velocity * 係数
const targetLookAhead = new Vec3(
velocity.x * this.velocityLookAheadFactor,
velocity.y * this.velocityLookAheadFactor,
0
);
// 最大距離を制限
const len = targetLookAhead.length();
if (len > this.maxLookAheadDistance) {
targetLookAhead.multiplyScalar(this.maxLookAheadDistance / (len || 1));
}
// 先読みオフセットをスムージング
Vec3.lerp(
this._currentLookAhead,
this._currentLookAhead,
targetLookAhead,
this.lookAheadSmoothing
);
// カメラの目標位置 = ターゲット位置 + 先読みオフセット
const desiredPos = new Vec3(
currentTargetPos.x + this._currentLookAhead.x,
currentTargetPos.y + this._currentLookAhead.y,
currentTargetPos.z // Zはターゲットと同じ(後でカメラ側のZに合わせる)
);
// 実際にカメラをどの座標空間で動かすか
if (this.useLocalSpace) {
// ワールドの目標位置をローカルに変換してから補間
const parent = this.node.parent;
if (parent) {
const desiredLocal = parent.inverseTransformPoint(new Vec3(), desiredPos);
const currentLocal = this.node.position.clone();
const newLocal = new Vec3();
Vec3.lerp(newLocal, currentLocal, desiredLocal, this.positionLerpFactor);
// Zは現在のローカルZを維持
newLocal.z = currentLocal.z;
this.node.setPosition(newLocal);
} else {
// 親がない場合はワールド=ローカルとして扱う
const currentPos = this.node.position.clone();
const newPos = new Vec3();
Vec3.lerp(newPos, currentPos, desiredPos, this.positionLerpFactor);
newPos.z = currentPos.z;
this.node.setPosition(newPos);
}
} else {
// ワールド座標で補間
const currentWorld = new Vec3();
this.node.getWorldPosition(currentWorld);
const newWorld = new Vec3();
Vec3.lerp(newWorld, currentWorld, desiredPos, this.positionLerpFactor);
// Zは現在のZを維持(カメラの奥行きは変えない)
newWorld.z = currentWorld.z;
this.node.setWorldPosition(newWorld);
}
// 次フレーム用にターゲット位置を保存
this._lastTargetPos.set(currentTargetPos);
// デバッグ表示
if (this.debugDrawGizmos && !game.isPaused()) {
this._debugDraw(currentTargetPos, desiredPos);
}
}
/**
* デバッグ用の簡易描画。
* Cocos Creator 標準の Gizmo API はエディタ拡張向けですが、
* ここではログと座標の確認に留めます。
*/
private _debugDraw(targetPos: Vec3, desiredPos: Vec3) {
// 必要に応じてログを絞る(毎フレーム出すと多すぎるので、たとえば秒間数回だけなど)
// ここではサンプルとして、たまにログを出す程度に留めます。
const t = Math.floor(director.getTotalFrames() % 30);
if (t === 0) {
console.log(
`[LookAhead Debug] target=(${targetPos.x.toFixed(2)}, ${targetPos.y.toFixed(2)})` +
` lookAhead=(${this._currentLookAhead.x.toFixed(2)}, ${this._currentLookAhead.y.toFixed(2)})` +
` desired=(${desiredPos.x.toFixed(2)}, ${desiredPos.y.toFixed(2)})`
);
}
}
}
コードの要点解説
-
onLoadtargetが設定されているかチェックし、未設定ならwarnを出しておきます。- ターゲットの初期位置を
_lastTargetPosに保存します。 positionLerpFactorとlookAheadSmoothingをmath.clamp01で 0〜1 にクランプしています。
-
update
フレームごとの処理の流れは次の通りです。- ターゲットの現在のワールド位置を取得。
- 前フレームの位置との差分から「速度ベクトル」を計算。
enableHorizontal/enableVerticalに応じて、X/Y成分を有効化/無効化。- 速度ベクトルに
velocityLookAheadFactorを掛けて「先読みオフセットの目標値」を計算。 maxLookAheadDistanceでオフセットの長さを制限。lookAheadSmoothingを使って_currentLookAheadを補間(オフセット自体をスムージング)。- 「ターゲット位置 + 先読みオフセット」をカメラの目標位置とし、
positionLerpFactorで現在位置から補間。 useLocalSpaceに応じて、ローカル座標 or ワールド座標でカメラ位置を更新。- 次フレーム用にターゲット位置を
_lastTargetPosに保存。 debugDrawGizmosが有効なら、たまにログを出力。
使用手順と動作確認
1. スクリプトファイルの作成
- エディタの Assets パネルで、適当なフォルダ(例:
assets/scripts/camera)を選択します。 - 右クリック → Create → TypeScript を選択します。
- ファイル名を
LookAhead.tsとして作成します。 - 作成された
LookAhead.tsをダブルクリックして開き、先ほどのコード全文を貼り付けて保存します。
2. テスト用シーンの準備
ここでは 2D 横スクロールゲーム風の簡単なテストを想定します。
- Hierarchy パネルで右クリック → Create → 2D Object → Sprite などを選択し、プレイヤー用ノード(例:
Player)を作成します。 Playerノードの Inspector でPositionを(0, 0, 0)付近に設定しておきます。- 同様に、床や背景などを適当に配置しておくと挙動の確認がしやすくなります(任意)。
3. カメラノードへのコンポーネント追加
- Hierarchy パネルで
Main Cameraノード(または使用中のカメラノード)を選択します。 - Inspector の下部にある Add Component ボタンをクリックします。
- Custom カテゴリから LookAhead を選択して追加します。
4. LookAhead プロパティの設定
Main Camera ノードの Inspector に、LookAhead コンポーネントが表示されているはずです。以下のように設定してみてください。
- Target:
Playerノードをドラッグ&ドロップして設定。 - Position Lerp Factor:
0.2(自然な追従感)。 - Velocity Look Ahead Factor:
0.5(進行方向の少し先を映す)。 - Max Look Ahead Distance:
5(ゲームスケールに応じて調整)。 - Look Ahead Smoothing:
0.4(オフセットの変化を適度になめらかに)。 - Enable Horizontal:
true(横方向の先読み有効)。 - Enable Vertical: 横スクロールなら
false、トップダウンならtrue。 - Use Local Space: 基本は
falseのままで問題ありません。 - Debug Draw Gizmos: 挙動確認中は
trueにしてログを見てもよいですが、本番ではfalse。
5. ターゲット(Player)を動かしてみる
ターゲットが動かないと先読みが発生しないため、簡単な移動スクリプトを用意するか、エディタのプレビュー中にスクリプトで動かしてください(ここでは例として、すでにプレイヤーが左右に動くスクリプトが付いている前提で説明します)。
- 上部の再生ボタン(Preview)を押してゲームを実行します。
- プレイヤーを左右に動かしてみてください。
- プレイヤーが右に動くと、カメラがプレイヤーの少し右側を映すように先読みされ、左に動くと左側を映すように変化します。
- プレイヤーが止まると、徐々に先読みオフセットが減少し、プレイヤーの位置付近に戻ってきます。
もしカメラが全く動かない場合は、次の点を確認してください。
LookAheadコンポーネントの Target にPlayerノードが正しく設定されているか。- プレイヤーが実際に動いているか(位置が変化しているか)。
Position Lerp Factorが 0 になっていないか。Velocity Look Ahead FactorやMax Look Ahead Distanceが小さすぎないか。
6. チューニングのコツ
- 「先読み感が強すぎる」場合は、
Velocity Look Ahead Factorを下げるか、Max Look Ahead Distanceを小さくします。 - 「カメラがプレイヤーに追いつくのが遅い」場合は、
Position Lerp Factorを少し上げます(例:0.3)。 - 「カメラがふらふらして酔いやすい」場合は、
Look Ahead Smoothingを下げて(例:0.2)、オフセットの変化をなめらかにします。 - ジャンプなどで縦方向のブレを抑えたい場合は、
Enable Verticalをfalseにして、先読みを横方向のみに限定すると安定します。
まとめ
今回実装した LookAhead コンポーネントは、
- カメラノードにアタッチして
- ターゲットノードをインスペクタで指定するだけで
- プレイヤーの進行方向を「少し先読み」して映してくれる
という、汎用的で再利用性の高いカメラ制御スクリプトです。
外部の GameManager や Rigidbody などの特定コンポーネントに依存せず、「ターゲットの位置の差分」だけから速度を推定しているため、さまざまなゲームタイプ(2D 横スクロール、トップダウン、ゆるい 3D アクションなど)でそのまま利用できます。
プロパティをインスペクタから細かく調整できるようにしているので、
- シビアなアクションゲーム向けのキビキビしたカメラ
- 探索系ゲーム向けのゆったりしたカメラ
- 上下のブレを抑えた横スクロール専用カメラ
といったバリエーションも、同じコンポーネントを使い回しながら簡単に実現できます。
プロジェクトの標準カメラとして LookAhead を組み込んでおくと、新しいシーンや新しいプレイヤーキャラクターを追加したときも、「ターゲットを差し替えるだけ」で同じ気持ちよさを再現できるようになり、開発効率と品質の両方を高められます。




