18
11

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

Unreal Engine (UE)Advent Calendar 2022

Day 5

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

Posted at

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

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

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

動作を把握する

あくまで目コピですが、概ね以下のような挙動ではないかとみています。
(図は2Dですが実際の計算内容は3D)

1. 先端の移動先への衝突を調べる

2. 衝突面の法線をもとに無限平面と回転先ベクトルとの交点を新たな目的地に設定し、次の判定を行う

3. 衝突がなくなるまで同様のことを繰り返し、交点に到達する

4. 終端から改めて目的地への衝突を調べ、カメラを壁に寄せる

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

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

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

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

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

実際のC++コード

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

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;
};
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の判定をチマチマ抜く前に、こういう方法で解決できないか一度ためしてみるといいですね。これだけの変更で様々な作業を省略できるなら、それに越したことはないです。

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

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

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

18
11
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
18
11

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?