「ひっかかり」のないカメラ制御を実現する方法

いきなりですが、スーパーマリオオデッセイのカメラがとても良いっていう話をします。

https://twitter.com/nemu90kWw/status/1599344588711694337

現代の三人称視点ゲームは、第一線のゲームであれどUEの標準SpringArmと基本が変わらないものも多いです。その伸縮動作は作品によって若干の差がありますが、とりわけマリオオデッセイのカメラ制御は利便性が高そうに思えます。不思議なことに近年のゲームでもあまり見かけません。なんででしょうね？（網羅的にゲームを把握してるわけじゃないので、これ以前の目撃情報があったら教えてください）

というわけで、UE上でこの挙動を再現し、SpringArmを置換してみよう、というのがこの記事の趣旨になります。
実際に動かしてみた映像がこちら。

https://twitter.com/nemu90kWw/status/1599536672638926848

# 動作を把握する
あくまで目コピですが、概ね以下のような挙動ではないかとみています。
（図は2Dですが実際の計算内容は3D）

1\. 先端の移動先への衝突を調べる
<img width="200" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/7085/0e7d01f5-bcef-3771-fd44-5301f596b717.png">

2\. 衝突面の法線をもとに無限平面と回転先ベクトルとの交点を新たな目的地に設定し、次の判定を行う
<img width="200" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/7085/92d9d5cd-0143-1736-afe6-d4102bc797b9.png">

3\. 衝突がなくなるまで同様のことを繰り返し、交点に到達する
<img width="200" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/7085/ec1567f0-2fe2-e3ff-37c6-84c2b298a3a0.png">

4\. 終端から改めて目的地への衝突を調べ、カメラを壁に寄せる
<img width="200" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/7085/c3560a75-418d-5e2d-8831-fc3b80392c42.png">

概念的な平面との交点を計算で得るので、Traceを飛ばす場合と異なり手前の壁が無視されます。壁ずりについてはこれで動作しますが、以下のケースでは別の動作が必要です。



<img width="200" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/7085/501485cd-89cf-357b-5694-a6695b348c1e.png">
<img width="200" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/7085/8b9e1c8d-ea95-f913-fdda-cb00703038fb.png">

左のケースは、カメラが壁の向こうから戻ってこれなくなります。
右のケースは、壁面との交点がプレイヤーの後方に飛んでしまいます。

<img width="200" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/7085/be96cf3f-1a9f-c990-2229-fe79dfb0fd34.png">
<img width="200" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/7085/66a3960c-7a09-e71d-482e-795789d7550c.png">

このような衝突面に遭遇したら、標準SpringArmと同じ動作をして、カメラを手前側に戻します。探索途中でも同様に処理することで、カメラが置いてけぼりになる状況が防がれます。

:::note info
上記2例を場合分けする必要はなく、どちらもプレイヤーから見て衝突面が直角より奥に傾いてるかどうかで判定できます
:::

探索イテレーションはループ処理なので、限界数を超えたらビジー扱いにして回避する処理も入れておきます（CharacterMovementにある処理と同じです）

## 実際のC++コード

以上を踏まえてSpringArm継承コンポーネントを作成すると、こうなります。
例示はUE5でのものですが原理的にはUE4でも同様に動作するはずです。
（元のSpringArmの変数がそのまま流用されているため、若干変数名がとっちらかっている）

```C++:OdysseySpringArmComponent.h
#pragma once

#include "CoreMinimal.h"
#include "GameFramework/SpringArmComponent.h"
#include "OdysseySpringArmComponent.generated.h"

UCLASS(ClassGroup = Camera, meta = (BlueprintSpawnableComponent), hideCategories = (Mobility))
class /*ここはモジュール名に依存*/ UOdysseySpringArmComponent : public USpringArmComponent
{
	GENERATED_BODY()
public:
	UOdysseySpringArmComponent(const FObjectInitializer &ObjectInitializer = FObjectInitializer::Get());

	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = CameraCollision, meta = (editcondition = "bDoCollisionTest", ClampMin = "1", ClampMax = "25", UIMin = "1", UIMax = "25"))
	int32 MaxSimulationIterations;

	virtual void PostLoad() override;

protected:
	FVector PreviousWorldLocation;
	virtual void UpdateDesiredArmLocation(bool bDoTrace, bool bDoLocationLag, bool bDoRotationLag, float DeltaTime) override;
};
```

```C++:OdysseySpringArm.cpp
#include "OdysseySpringArmComponent.h"
#include "DrawDebugHelpers.h"
#include "PhysicsEngine/PhysicsSettings.h"

UOdysseySpringArmComponent::UOdysseySpringArmComponent(const FObjectInitializer &ObjectInitializer)
	: Super(ObjectInitializer)
{
	MaxSimulationIterations = 8;
}

void UOdysseySpringArmComponent::PostLoad()
{
	Super::PostLoad();
	PreviousWorldLocation = GetComponentLocation();
}

void UOdysseySpringArmComponent::UpdateDesiredArmLocation(bool bDoTrace, bool bDoLocationLag, bool bDoRotationLag, float DeltaTime)
{
    ...（略）...
	// Now offset camera position back along our rotation
	FVector ArmDirection = -DesiredRot.Vector();
	DesiredLoc += ArmDirection * TargetArmLength;
	// Add socket offset in local space
	DesiredLoc += FRotationMatrix(DesiredRot).TransformVector(SocketOffset);

	// Do a sweep to ensure we are not penetrating the world
	FVector ResultLoc;
	if (bDoTrace && (TargetArmLength != 0.0f))
	{
		FCollisionQueryParams QueryParams(SCENE_QUERY_STAT(SpringArm), false, GetOwner());

		FVector Start = PreviousWorldLocation;
		FVector End = DesiredLoc;
		FHitResult Hit;

		int32 Iterations = 0;
		while (true)
		{
			Iterations++;

			if (!GetWorld()->SweepSingleByChannel(Hit, Start, End, FQuat::Identity, ProbeChannel, FCollisionShape::MakeSphere(ProbeSize), QueryParams))
			{
				break;
			}

			if (((Hit.Location - PreviousDesiredLoc) | Hit.Normal) >= 0 || Iterations >= MaxSimulationIterations)
			{
				End = PreviousDesiredLoc;
				break;
			}

			Start = Hit.Location + Hit.Normal * .01f;
			End = PreviousDesiredLoc + ArmDirection * ((Hit.Location - PreviousDesiredLoc + Hit.Normal * .01f) | Hit.Normal) / (ArmDirection | Hit.Normal);
		}

		if (End != DesiredLoc)
		{
			bIsCameraFixed = GetWorld()->SweepSingleByChannel(Hit, End, DesiredLoc, FQuat::Identity, ProbeChannel, FCollisionShape::MakeSphere(ProbeSize), QueryParams);
			ResultLoc = bIsCameraFixed ? Hit.Location : DesiredLoc;
		}
		else
		{
			bIsCameraFixed = false;
			ResultLoc = DesiredLoc;
		}
	}
	else
	{
		ResultLoc = DesiredLoc;
		bIsCameraFixed = false;
	}
	UnfixedCameraPosition = DesiredLoc;
	PreviousWorldLocation = ResultLoc;

	// Form a transform for new world transform for camera
    ...（略）...
}
```

SpringArmのUpdateDesiredArmLocationには、CameraLagに関するプログラムも含まれているため、オーバーライドして作ろうとすると大部分をコピペする必要があるのがちょっと残念ですね（前後関係は原文のコメント位置から把握して）

## 簡単便利

柵や柱のコリジョンプロファイルからCameraの判定をチマチマ抜く前に、こういう方法で解決できないか一度ためしてみるといいですね。これだけの変更で様々な作業を省略できるなら、それに越したことはないです。

カメラ制御はゲームによって正解が異なるので、一概にこの方法が良いとも言えません。（例えば偶発的にプレイヤーを完全に隠す形になってしまうのは、ジャンルによっては不都合が大きい）
他のカメラ制御方法については、ついこないだヒストリアさんも話題にしていたので、そちらも参考になります。

https://historia.co.jp/archives/30743/

（余談ですが、厳密に言えばマリオオデッセイのカメラは伸張時に若干の補間処理が挟まっており、実は上記記事とのハイブリッドな動作です。そこまでやるとややこしいので本記事では省いています）

こういう細かいところでゲームの快適性は結構変わってくるものなので、いろいろ試して快適カメラライフを実現してください。