Как мы реализовали систему камер для мобильной TPS игры

8b2aed7dd0f53758557484fa33942e83.jpg

Содержание

Решаемая проблема

В консольных ААА играх мы видим динамическую камеру от третьего лица, которая в процессе игры постоянно двигается, меняет планы. Иногда это нужно для художественных задач, например, приблизить камеру, чтобы вызвать ощущение клаустрофобии или создать напряжение. Иногда, чтобы показать общий план, и акцентировать внимание на масштабе противника и игрока. Но чаще основная задача камеры — показать игроку то, что он должен и хочет видеть в данный момент времени.Показать те объекты, с которыми он сейчас взаимодействует, и не вынуждать его лишний раз вращать камеру вручную.

Рассмотрим несколько примеров с точки зрения дизайна, о которых пойдет речь в статье.

Обычный вид камеры

Игрок немного сбоку. Мы освобождаем центральную область экрана, чтобы игрок видел куда он идёт. 

The Last of us Part IIThe Last of us Part II

Прицеливание

При стрельбе от третьего лица нужно приблизить камеру, уменьшить FoV (Field of View), чтобы лучше видеть цель. И изменить позицию камеры, чтобы создавалось впечатление, что мы целимся вместе с игровым персонажем.

Division 2Division 2

Поведение камеры в бою

В бою мы отдаляем камеру, чтобы игрок видел больше пространства вокруг игрового персонажа. Это нужно, чтобы видеть противников, кого атаковать, от кого уворачиваться.

Assassin's Creed OdysseyAssassin’s Creed Odyssey

Камера на бегу

В спринте выгодно отдалить камеру и увеличить угол обзора FoV. Это нужно, чтобы в экран попадало больше объектов, и игрок успевал управлять движением персонажа на большой скорости. Также FoV даёт дополнительный визуальный эффект, которым в играх принято обозначать ускорение.

Batman​ Arkham KnightBatman​ Arkham Knight

Камера внутри помещений

Внутри помещений мы сталкиваемся с другой организацией пространства. В играх размерности комнат и зданий обычно больше и просторней реальных, но они всё равно стесняют движения персонажа и камеры. Для того чтобы камера лишний раз не «спотыкалась» об окружение, в помещениях мы держим камеру ближе к игроку. Также это создаёт некоторое ощущение замкнутости окружающего пространства, позволяет игроку ощутить себя в тесноте.

Red Dead Redemption 2Red Dead Redemption 2

Конечно случаев, требующих уникальной настройки, может быть больше. Например, у нас в проекте их более 40. Описываемая система позволяет легко создавать для таких случаев готовые наборы настроек.

Цели и задачи системы

  1. Реализовать консольный фил. Поведение камеры похожее на поведение в ААА-консольных играх. 

  2. Удобство настройки. Все настройки и создание нового контента должны происходить без написания кода.

  3. Масштабируемость. Возможность расширять и дополнять систему.

Основные элементы системы

Всистеме есть три основных элемента.

Первый элемент — режим камеры

Режим камеры (camera mode) реализован как DataAsset, который содержит все необходимые настройки, выставляющие камеру в нужное положение.

Внутри camera mode настраиваются простейшие параметры, такие как:

Pitch. Вверх/вниз.

142826472d0080c2527d049e84699d20.gif

Yaw. Влево/вправо.

3c53df021ea3c6bdf2d814bdf8eab503.gif

Roll. Вращение.

52de88d01dff83215ba0afc0f9a85fbf.gif

Distance. Или Arm Length в UE4. Дистанция до игрока.

4f37148bf0dc266c01b235f992a17408.gif

Offset. Смещение камеры.

6558feedce94818cdceaf4f0435dc7d9.gif

FoV. Поле зрения.

9ff51e85ce1f65ac0fb232dba5ebd32b.gif

Ниже показано, как изменение отдельных параметров камеры постепенно приводит её в то состояние, которое нам требуется в конкретном camera mode.

276cb666af843945d0af05bb410bc220.gif

Второй элемент — вспомогательные системы

Вспомогательные системы (subsystems) являются отдельными механиками, влияющими на поведение камеры, в зависимости от определенных условий и настроек. 

Эти системы выполняют различные задачи. Например, Subsystem pitch position автоматически выставляют камеру в определённые Pitch и Yaw параметры, при движении игрока.

dc5255fee7de75ad03ab91273c7339b3.gif

Subsystem auto rotation поворачивает камеру по направлению движения игрока.

0145c07491f54c0fc4f06225bee1f7b2.gif

Subsystem focus target включают фокус на цель в бою.

e3ba9b99d19f3ec344568cf9149e6a7e.gif

Каждая такая subsystem — это отдельный модуль. Модульный подход удобен по множеству причин:

  1. Не каждому режиму камеры нужны все вспомогательные системы.

  2. Куда легче работать с кодом и блупринтами, когда ассет не переполнен множеством механик собранных в кучу.

  3. Есть возможность параллельной разработки нескольких subsystems сразу. 

  4. Четкий и явный порядок выполнения subsystems, воздействующих на камеру.

Subsystems — это отдельная большая тема, мы не будем углубляться в неё в этой статье. Просто обозначим, что они есть. Результат, на некоторых представленных материалах из игры, не был бы достигнут без этих систем.

Третий элемент — переходы между режимами

Система выбирает тот или иной режим, исходя из условий, в которых находится игровой персонаж.

Примеры некоторых режимов камеры с условиями перехода:  

  1. Base mode. Обычный вид камеры.

    • Обычное оружие в руках. 

    • Игрок стоит на улице.

    • Противников рядом нет.

  2. Battle mode. Поведение камеры в бою.

    • Рядом находятся противники. 

    • Игрок наносит удары по противникам.

  3. Aim mode. Прицеливание.

    • У игрока в руках стрелковое оружие.

    • Игрок активировал прицеливание.

  4. Sprint mode. Камера на бегу.

    • Игрок начал двигаться. 

    • Игрок активировал ускорение.

  5. Indoor mode. Камера внутри помещений.

    • Игрок находится внутри помещения.

С точки зрения дизайна, мы всегда однозначно знаем в каком режиме камеры должен находиться игрок в зависимости от условий. Но может так получиться, что два режима хотят активироваться одновременно.

Например:  

Игрок находится в бою с противниками. Включен Battle mode камеры. Но при этом он достал стрелковое оружие и активировал прицеливание. В таком случае мы хотим активировать Aim mode.

Чтобы разрешать такие ситуации у режимов есть приоритизация. Порядок, согласно которому они активируются. На картинке ниже Aim mode более приоритетный, чем Battle mode.

1bd92dc8575388550cc9fc3c8e505652.jpg

Нужна была гибкая, простая в плане настроек и прозрачная система для смены camera mode, чтобы она могла бы настраиваться гейм-дизайнерами без участия программистов. В итоге было решено сделать систему перехода от одного мода к другому, базирующуюся наgameplay tags.

Реализация

Режимы камеры

Дляначала необходимо создать C++ проект из шаблона Third Person.

Набор состояний персонажа мы будем хранить в виде TMap, где ключ — FGameplayTag, а значение — количество тегов. Это необходимо, когда теги накладываются и убираются из разных источников.

Так же необходимы функции для добавления/удаления тегов, геттер, а также делегат, который будет вызываться при их изменении. Не забудьте добавить модуль GameplayTags в build.cs файле.

Код
//CameraSystemCharacter.h

public:
DECLARE_EVENT_TwoParams(ACameraSystemCharacter, FOnTagContainerChanged, const FGameplayTag& /*ChangedTag*/, bool /*bExist*/);
FOnTagContainerChanged OnTagContainerChanged;

void AddTag(const FGameplayTag& Tag);
void RemoveTag(const FGameplayTag& Tag);
FGameplayTagContainer GetGameplayTags() const;

protected:
TMap TagMap;
Код
//CameraSystemCharacter.срр

void ACameraSystemCharacter::AddTag(const FGameplayTag& Tag)
{
   auto& val = ++TagMap.FindOrAdd(Tag);
   OnTagContainerChanged.Broadcast(Tag, val > 0);
}
void ACameraSystemCharacter::RemoveTag(const FGameplayTag& Tag)
{
   auto& val = --TagMap.FindOrAdd(Tag);
   OnTagContainerChanged.Broadcast(Tag, val > 0);
}
FGameplayTagContainer ACameraSystemCharacter::GetGameplayTags() const
{
   FGameplayTagContainer tags;
	 Algo::ForEach(TagMap, [&](const auto& it) { if (it.Value > 0) tags.AddTag(it.Key); });
	 return tags;
}

После этого добавим компонент, который будет отвечать за систему режимов камеры и их работу. 

Код
//CameraModeComponent.h

UCLASS( ClassGroup=(Custom), meta=(BlueprintSpawnableComponent) )
class CAMERASYSTEMHABR_API UCameraModeComponent : public UActorComponent
{
	GENERATED_BODY()

};

Также необходимо создать класс для camera mode. Каждый camera mode представлен в виде DataAsset с набором настроек для требуемого поведения.

Код
//CameraMode.h

UCLASS(Abstract, Blueprintable, editinlinenew)
class UCameraMode: public UDataAsset
{
  ...
};

В CameraModeComponent необходимо объявить структуру, которая  должна содержать Camera mode и FGameplayTagQuery, который определяет условия для перехода в этот режим, исходя из состояния персонажа на данный момент.

8be0319ae3cb9275c8082ddaa3387679.pngКод
//CameraMode.h

USTRUCT(BlueprintType)
struct FCameraModeData
{
	GENERATED_BODY()
public:
bool CanActivateMode(const FGameplayTagContainer& TagsToCheck) const
{
  return ModeActivationConditions.IsEmpty() || ModeActivationConditions.Matches(TagsToCheck);
}

UCameraMode* GetCameraMode() const
{
	return CameraMode;
}

protected:
	UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Instanced)
	UCameraMode* CameraMode;

	UPROPERTY(EditDefaultsOnly, BlueprintReadOnly)
	FGameplayTagQuery ModeActivationConditions;
};

Добавим массив конфигов для всех возможных режимов камеры в компонент и переменную текущего camera mode, а также функцию callback, которая будет вызываться при изменении тегов на персонаже.

Код
//CameraModeComponent.h

UCLASS( ClassGroup=(Custom), meta=(BlueprintSpawnableComponent) )
class CAMERASYSTEMHABR_API UCameraModeComponent : public UActorComponent
{
	GENERATED_BODY()
   ...

protected:
virtual void BeginPlay() override();
void OnAbilityTagChanged(const FGameplayTag& Tag, bool TagExists);

protected:
UPROPERTY(EditDefaultsOnly)
TArray CameraModes;

UPROPERTY()
UCameraMode* CurrentCameraMode;

TWeakObjectPtr Character;

  ...

};
Код
//CameraModeComponent.cpp

void UCameraModeComponent::BeginPlay()
{
	Super::BeginPlay();

	Character = CastChecked(GetOwner());

	if (Character->IsLocallyControlled())
	{
		Character->OnTagContainerChanged.AddUObject(this, &UCameraModeComponent::OnAbilityTagChanged));
	}
}

Дальше, после смены тега, необходимо определить, подходит ли один из camera mode из конфига под текущие условия и, если да, сменить camera mode на новый.

Код
//CameraModeComponent.cpp

void UCameraModeComponent::OnAbilityTagChanged(const FGameplayTag& Tag, bool TagExists)
{
  SetCameraMode(DetermineCameraMode(Character->GetGameplayTags()));
}

UCameraMode* UCameraModeComponent::DetermineCameraMode(const FGameplayTagContainer& Tags) const
{
  if (auto foundMode = Algo::FindByPredicate(CameraModes, [&](const auto modeData) {return modeData.CanActivateMode(Tags);}))
{
	return foundMode->GetCameraMode();
}

  return nullptr;
}

void UCameraModeComponent::SetCameraMode(UCameraMode* NewMode)
{
	CurrentCameraMode = NewMode;
}

Для примера сделаем два режима камеры и переходы между ними. Базовый и во время бега персонажа. По аналогии можно добавлять любое количество состояний для перехода в различные режимы камеры, например для прицеливания, боя и тд.

Прежде всего в ProjectSettings надо завести gameplay tags для состояний персонажа, влияющих на камеру. В данном случае это состояние, когда персонаж бежит.

CharacterState.Sprint

c173010a20fabde6200ce3b559a81fe8.png

Далее в персонаже заведём ещё несколько методов, для привязки клавиш и включения/выключения состояния бега:

Код
//CameraSystemCharacter.h

virtual void SetupPlayerInputComponent(class UInputComponent* PlayerInputComponent) override;

void EnableSprint();
void DisableSprint();
Код
//CameraSystemCharacter.cpp

void ACameraSystemCharacter::SetupPlayerInputComponent(class UInputComponent* PlayerInputComponent)
{
  PlayerInputComponent->BindAction("Sprint", IE_Pressed, this, &ACameraSystemCharacter::EnableSprint);

  PlayerInputComponent->BindAction("Sprint", IE_Released, this, &ACameraSystemCharacter::DisableSprint);
}

void ACameraSystemCharacter::EnableSprint()
{ AddTag(FGameplayTag::RequestGameplayTag(TEXT("CharacterState.Sprint")));
}

void ACameraSystemCharacter::DisableSprint()
{
RemoveTag(FGameplayTag::RequestGameplayTag(TEXT("CharacterState.Sprint")));
}

После этого необходимо в редакторе создать два ассета UCameraMode и выбрать их в конфиге Camera mode component. Базовый Camera mode необходимо разместить в самом конце, т.к. он не имеет условий для перехода и активируется только в случае, если ни один режим камеры не подошёл под текущие условия.

b815699055a9e75eb896eccf0baee0ab.png

Дальше настраиваем условия перехода.

Переход в Sprint mode осуществляется при наличии тега CharacterState.Sprint.

374d09833cfc24bcaf22010f7854f3ff.png

Интерполяция при переключении

Необходимо, чтобы один мод плавно переходил в другой при помощи интерполяции параметров. Ниже показано переключение Base mode в Sprint mode и обратно с интерполяцией и без.

52a0ae41fd733abde04d3971813022b8.gif

В UCameraMode добавляем настройки положения камеры и скорости перехода между режимами камеры.

Код
//CameraMode.h

UCLASS(Abstract, Blueprintable, editinlinenew)
class UCameraMode : public UDataAsset
{
	GENERATED_BODY()
public:
//Cкорость интерполяции для текущего мода
UPROPERTY(EditDefaultsOnly, meta = (ClampMin = 0.f, UIMin = 0.f))
float InterpolationSpeed = 5.f;

//Продолжительность смены скорости интерполяции при переходе из одного мода в другой, требуется, чтобы достичь максимальной плавности при переходе между модами.
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, meta = (ClampMin = 0.f, UIMin = 0.f))
float InterpolationLerpDuration = 0.5f;

//Длина SprintArm в камера моде
UPROPERTY(EditDefaultsOnly, meta = (ClampMin = 0.f, UIMin = 0.f))
float ArmLength = 250.f;

//Значение FOV для камеры
UPROPERTY(EditDefaultsOnly, meta = (ClampMin = 0.f, UIMin = 0.f))
float Fov= 60.f;

//оффсет камеры относительно SprigArm
UPROPERTY(EditDefaultsOnly)
FVector CameraOffset = FVector::ZeroVector;

//Параметр, который будет включать вращение персонажа по направлению контроллера, например, для режима прицеливания
UPROPERTY(EditDefaultsOnly)
bool bUseControllerDesiredRotation = false;
};

Далее в Camera component tick добавляем методы, которые будут отвечать за интерполяцию значений:

Код
//CameraModeComponent.h

protected:
virtual void TickComponent(float DeltaTime, ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction) override;

virtual void BeginPlay() override;
void SetCameraMode(UCameraMode* NewMode);

float GetInterpSpeed() const;
void UpdateCameraMode (float DeltaTime);
void UpdateSpringArmLength(float DeltaTime);
void UpdateCameraLocation(float DeltaTime);
void UpdateFOV(float DeltaTime);

//Мировое время, во время смены одного камера мода на другой, потребуется, чтобы реализовать плавный переход скорости интерполяции между модами
float TimeSecondsAfterSetNewMode = 0.f;

//Скорость интерполяции с прошлого мода, требуется, чтобы плавно перейти в новую скорость интерполяции
float PreviousInterpSpeed = 0.f;
//Камера менеджер, пригодится в  дальнейшем для смены значений FOV	
TWeakObjectPtr PlayerCameraManager;
Код
//CameraModeComponent.cpp

void UCameraModeComponent::SetCameraMode(UCameraMode* NewMode)
{
  if (CurrentCameraMode != NewMode)
	{
	  PreviousInterpSpeed = CurrentCameraMode == nullptr ? NewMode->InterpolationSpeed : CurrentCameraMode->InterpolationSpeed;
	  CurrentCameraMode = NewMode;


    Character->GetCharacterMovement()->bUseControllerDesiredRotation = CurrentCameraMode->bUseControllerDesiredRotation;
    Character->GetCharacterMovement()->bOrientRotationToMovement = !CurrentCameraMode->bUseControllerDesiredRotation;

    TimeSecondsAfterSetNewMode  = GetWorld()->GetTimeSeconds();
	}
}

void UCameraModeComponent::BeginPlay()
{
	...
  PlayerCameraManager = CastChecked (Character->GetController())->PlayerCameraManager;
	...
}

float UCameraModeComponent::GetInterpSpeed() const
{
	auto timeAfterModeWasChanged = GetWorld()->GetTimeSeconds() - TimeSecondsAfterSetNewMode;
	auto lerpDuration = CurrentCameraMode->InterpolationLerpDuration;
	auto lerpAlpha = FMath::IsNearlyZero(lerpDuration) ? 1.f : FMath::Min(1.f, timeAfterModeWasChanged / lerpDuration);
	return FMath::Lerp(PreviousInterpSpeed, CurrentCameraMode->InterpolationSpeed, lerpAlpha);
}

void UCameraModeComponent::TickComponent(float DeltaTime, ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction)
{
	Super::TickComponent(DeltaTime, TickType, ThisTickFunction);

	UpdateCameraMode(DeltaTime);
}

void UCameraModeComponent::UpdateCameraMode(float DeltaTime)
{
if (CurrentCameraMode != nullptr)
  {
    UpdateSpringArmLength(DeltaTime);
    UpdateSpringArmPivotLocation(DeltaTime);
    UpdateCameraLocation(DeltaTime);
  }
}

//Интерполяция параметра TargetArmLength
void UCameraModeComponent::UpdateSpringArmLength(float DeltaTime)
{
 	const auto currentLength = Character->GetCameraBoom()->TargetArmLength;

	const auto targetLength = CurrentCameraMode->ArmLength;

	const auto newArmLength = FMath::FInterpTo(currentLength, targetLength, DeltaTime, GetInterpSpeed());

	Character->GetCameraBoom()->TargetArmLength = newArmLength;
}

void UCameraModeComponent::UpdateCameraLocation(float DeltaTime)
{
	const auto currentLocation = Character->GetCameraBoom()->SocketOffset;
	const auto targetLocation = CurrentCameraMode->CameraOffset;
	FVector newLocation = FMath::VInterpTo(currentLocation, targetLocation, DeltaTime, GetInterpSpeed());

	Character->GetCameraBoom()->SocketOffset = newLocation;
}

//Смена значений FOV
void UCameraModeComponent::UpdateFOV(float DeltaTime)
{  
	const auto currentFov = PlayerCameraManager->GetFOVAngle();
	const auto targetFov = CurrentCameraMode->Fov;
	auto newFov = FMath::FInterpTo(currentFov, targetFov, DeltaTime, GetInterpSpeed());
	
	PlayerCameraManager->SetFOV(newFov);
}

Таким образом, после смены режима камеры на новый, будет происходить плавный переход между ними.

Результат

Навидео показано, как работает переход между режимами камеры у нас в проекте. Конечно, на видео немного более сложная система, чем та, которая описана в статье. Так как в рамках одной статьи невозможно описать все элементы системы камер.

Мы подготовили небольшой проект на GitHub, в котором реализованы описанные в статье camera modes. Ссылка для скачивания.

2ace3ea0d7d8adc03a4430d8864da9af.gif

Над статьёй работали:

Дмитрий Горбачев, Technical Game Designer
Дмитрий Вергасов, C++ programmer
Кирилл Минцев, C++ programmer

© Habrahabr.ru