ALS Plugin 카메라

post_thumbnail

  • 기존 ALS 플러그인에서 사용하는 카메라 데이터를 확장해 사용하고 있었다
  • CameraComponent를 사용하지 않고, CameraManager를 상속해 로직을 확장했다
  • Camera 전용 AnimBlueprint를 만들어 카메라 State 간의 트랜지션을 관리했다
    • Animation에서 Blending 하는 로직을 응용한 것이다
    • Modify Curve 노드를 이용해 각 변수들의 값이 Target을 향해 Interpolocation 된다

CameraComponent 없어도 되는 거였나

void APlayerCameraManager::UpdateViewTarget(FTViewTarget& OutVT, float DeltaTime)
{
  // ...
  else
  {
    UpdateViewTargetInternal(OutVT, DeltaTime);
  }
  // ...
}

void APlayerCameraManager::UpdateViewTargetInternal(FTViewTarget& OutVT, float DeltaTime)
{
  if (OutVT.Target)
  {
    // ...

    else
    {
      OutVT.Target->CalcCamera(DeltaTime, OutVT.POV);
    }
  }
}

void AActor::CalcCamera(float DeltaTime, FMinimalViewInfo& OutResult)
{
  if (bFindCameraComponentWhenViewTarget)
  {
    // ...
    for (UCameraComponent* CameraComponent : Cameras)
    {
      if (CameraComponent->IsActive())
      {
        CameraComponent->GetCameraView(DeltaTime, OutResult);
        return;
      }
    }
  }

  // ...
}
  • CameraComponent가 실제로 카메라에 영향을 미치려면 APlayerCameraManager의 UpdateViewTarget 함수를 통해 CalcCamera에서 OutResult의 값을 변경해주는 플로우로 진행해야 된다
  • 그러나 ALSPlayerCamera에서 UpdateViewTargetInternal 함수를 호출하지 않고 있다
    • PlayerController는 당연히 CameraManager 클래스로 ALSPlayerCamera를 상속한 CustomPlayerCamera를 쓰는 중
  • FTViewTarget는 CameraComponent의 존재 여부와 상관없이 APlayerCameraManager에 의해 관리 및 프로세싱 되고 있다
    • 카메라 로직을 PlayerCameraManager 안에서만 관리하고 싶었나 보다…

카메라 기획

  • 카메라 구도 기획 단계에서 몇몇 조건이 필요했다
    • 캐릭터의 상태(비전투, 전투, 특수 액션 등) 케이스별로 구도를 잡을 수 있어야 한다
    • 몇몇 캐릭터 상태는 게임 전체에 걸쳐 동일한 값을 사용하지만, 또 몇몇은 무기 혹은 지역에 따라 바뀐다
    • Base가 되는 구도가 있고, 그 구도를 기준으로 Offset을 추가하거나 Override 하는 방식이어야 했다

카메라 State와 트랜지션

USTRUCT(BlueprintType)
struct FCameraViewData
{
  GENERATED_BODY()

public:
  static FCameraViewData Interp(const FCameraViewData& Source, const FCameraViewData& Target, float DeltaTime, float InterpSpeed);
  static bool IsDoneInterp(const FCameraViewData& Source, const FCameraViewData& Target);

  FCameraViewData operator+(const FCameraViewData& RHS) const;
  void SetAsDefault();
  
public:
  UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Offset")
  FVector CameraOffset = FVector(-350.f, 0.f, 75.f);

  UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Offset")
  FVector PivotOffset = FVector::ZeroVector;

  // 카메라가 피봇을 따라가는 스피드
  UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Lag Speed")
  FVector PivotLagSpeed = FVector(5.f, 5.f, 15.f);

  // 카메라 회전 속도 - 이동모드에서의 기본값은 20이다. 이 값이 너무 작을 경우 카메라의 회전이 너무 늦게 따라와서 조작이 불편할 수도 있음. 사격모드에서는 20을 유지하도록 하자.
  UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Lag Speed")
  float RotationLagSpeed = 15.f;

  UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "FOV", AdvancedDisplay)
  float CameraFOV = 90.f;

  // 카메라가 최대로 올려다볼 수 있는 각도 
  UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Pitch",DisplayName="PitchMax", meta=(UIMin=-89.9f, UIMax=89.9f, ClampMin=-89.9f, ClampMax=89.9f))
  float CameraPitchMax = 89.f;
  
  // 카메라가 최대로 내려다볼 수 있는 각도 
  UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Pitch",DisplayName="PitchMin", meta=(UIMin=-89.9f, UIMax=89.9f, ClampMin=-89.9f, ClampMax=89.9f))
  float CameraPitchMin = -89.f;
};
  • 기본 인게임 백뷰 카메라 데이터 구조는 위와 같다
    • 하드락인 경우에는 별도의 구조체 데이터를 사용한다
  • AnimGraph에서는 BlendPoses 노드나 BooleanPoses를 사용해 전환
  • State 안에서트 Transition을 사용해 전환한다
  • State 변경 시, 변경할 State 값을 미리 가지고 있다
    • AnimGraph에서는 Pose Cache로 Target State Value를 정의한다
    • State 안에서는 각 값에 대한 Target을 ModifyCurve 노드로 정의한다
  • 다행히 게임 전체에 걸쳐 State와 구도가 1:1인 경우에는 이렇게 할 수 있다
    • 그런데 State와 사용할 구도가 1:N인 경우라면?

같은 State, 다른 구도

  • 캐릭터는 크게 전투, 비전투 State를 나눠 구도를 관리한다
  • 전투 상황은 Walk, Run, Sprint 3가지 상태로 각각 구분한다
  • 그런데 이 3가지 상태를 무기별로 다른 구도로 적용하고 싶다는 기획 요청이 있었다
    • 이게 위에서 설명한 State와 카메라 구도의 1:N 상황이다
void UCustomALSPlayerCameraBehavior::NativeUpdateAnimation(float DeltaSeconds)
{
  Super::NativeUpdateAnimation(DeltaSeconds);

  if (IsValid(CameraBehaviorDataAsset))
  {
    // Interp 하지 않거나 Interp를 하다가 완료한 경우에는 중도 return
    if (bTickInterp == false
      || (IsDoneInterp() && bTickInterp))
    {
      bTickInterp = false;
      return;
    }
    
    CurrentBehavior.NormalView_VelocityDirection_Default = FCameraViewData::Interp(CurrentBehavior.NormalView_VelocityDirection_Default,
      CameraBehaviorDataAsset->CameraBehavior.NormalView_VelocityDirection_Default.GetViewData(), DeltaSeconds, BehaviorBlendSpeed);
    CurrentBehavior.NormalView_VelocityDirection_Running = FCameraViewData::Interp(CurrentBehavior.NormalView_VelocityDirection_Running,
      CameraBehaviorDataAsset->CameraBehavior.NormalView_VelocityDirection_Running.GetViewData(), DeltaSeconds, BehaviorBlendSpeed);
    
    // ...
  }
}
  • 코드로 일일이 밀어넣어줘야 했다
    • 모든 데이터를 캐싱할 수는 없으니까..
    • 결국 초기화 타이밍 이외에 다시 알맞은 데이터 에셋으로부터 값을 읽어와야 한다
  • 또 특정 지역이나 이벤트 발생으로 인한 인게임 카메라 구도는 Stack으로 관리 즉 누적된다는 기획 요청도 있었다
UCLASS(Blueprintable, BlueprintType)
class UCustomPlayerCameraBehavior : public UALSPlayerCameraBehavior
{
  // ...
  // 카메라 적용 및 해제
  const FCameraInfoHandle& AddCameraBehavior(const FCameraInfo& CameraInfo);
  void RemoveCameraBehavior(const struct FCameraInfoHandle& CameraInfoHandle);
  void ClearCameraBehaviors();
  bool HasCameraBehavior(const FCameraInfoHandle& CameraInfoHandle);

  // ...
  protected:
    void ApplyCameraInfo();

  // ...

  UPROPERTY(Transient)
  TArray<struct FCameraInfo> CameraStack;
  // ...
}
  • AnimInstance에서 카메라 데이터를 누적 관리하는 구조체를 만들고, 할당 및 해제가 필요한 경우를 처리했다
  • 어떤 카메라 데이터를 가지고 있는지 여부 확인 및 카메라 우선순위에 따라 Sort하는 등의 이유로 Stack 대신 Array를 사용했다
    • 레벨에 배치한 지역 카메라 볼륨, 특정 몬스터와 전투 시 구도 변경 레벨 이벤트 호출 등 타입이 있었고 이에 따른 우선순위가 달랐다
const FCameraInfoHandle& UCustomPlayerCameraBehavior::AddCameraBehavior(const FCameraInfo& CameraInfo)
{
  CameraElements.Add(CameraInfo);
  ApplyCameraInfo();
  return CameraInfo.Handle;
}

void UCustomPlayerCameraBehavior::ApplyCameraInfo()
{
  // Array에 카메라 데이터 남아있다면 남아있는 것 중 최우선 순위의 카메라 데이터 적용
  CameraElements.Sort(FCameraInfo::FSortPredicate());
  if (CameraElements.IsEmpty() == false)
  {
    CameraBehaviorDataAsset = CameraElements[0].CameraBehaviorAsset;	
  }
  // 없으면 기존 카메라로 복귀
  else
  {
    CameraBehaviorDataAsset = DefaultCameraBehaviorDataAsset;
  }

  // ...
}

void UCustomPlayerCameraBehavior::RemoveCameraBehavior(const struct FCameraInfoHandle& CameraInfoHandle)
{
  // ...

  if (CameraInfoHandle.IsValid())
  {
    // ...

    // 카메라 데이터 삭제
    CameraElements.RemoveAll([&CameraInfoHandle] (const FCameraInfo& Element)
    {
      return Element.Handle == CameraInfoHandle;
    });
    
    CameraElements.Sort(FCameraInfo::FSortPredicate());
  }

  // ...
  
  // 지연 삭제가 아니라면 즉시 적용
  ApplyCameraInfo();	
  
  // ...
}

bool UCustomPlayerCameraBehavior::HasCameraBehavior(const struct FCameraInfoHandle& CameraInfoHandle)
{
  if (CameraInfoHandle.IsValid())
  {
    CameraElements.FindByPredicate([&CameraInfoHandle] (const FCameraInfo& Element)
    {
      return Element.Handle == CameraInfoHandle;
    });
  }
  
  return false;
}
  • 위의 메서드들이 카메라 구도 적용 및 해제 상황에서 CameraAnimInstance를 통해 직접 호출된다
  • 카메라 데이터 할당 시 Handle을 생성하고 구조체가 Handle을 가지고 있으며, 탐색 시 Handle로 구분하도록 했다
    • Map으로 할 수도 있었겠지만, Array로 Sort한 결과를 그대로 쓰는게 더 쉬울 듯

기본 카메라 응용 기능

post_thumbnail

  • 상기한 카메라 구조체는 하나의 데이터 에셋으로서 존재한다

post_thumbnail

  • 그리고 이런 하나의 카메라 구도 에셋을 여러 개 모아 상황별 카메라 데이터 에셋을 만든다

post_thumbnail

  • 이 데이터 에셋에서 기준이 되는 카메라 구도 + 오프셋 혹은 오버라이드가 필요하다
  • 위 그림에서 Loaded View Data + View Data Offset = View Data 로 연산된다
    • View Data가 실제 인게임에서 로드되어 적용할 카메라 데이터이다
    • 에디터 상에서 Loaded View Data의 값이 바뀌면 View Data도 바뀌어야 한다
void UCameraBehaviorDataBundleAsset::PostEditChangeChainProperty(FPropertyChangedChainEvent& PropertyChangedEvent)
{
  const bool bChangedViewDataOffset = FindEqualFieldName(PropertyChangedEvent, TEXT("ViewDataOffset"));
  
  // 일반 번들의 ViewDataAsset가 변경된 경우
  if (PropertyChangedEvent.Property->GetName() == TEXT("ViewDataAsset")
    || bChangedViewDataOffset)
  {
    for (TFieldIterator<FStructProperty> CameraBehaviorPropIt(FCameraBehaviorDataBundle::StaticStruct())
      ; CameraBehaviorPropIt; ++CameraBehaviorPropIt)
    {
      const FString FieldName = *CameraBehaviorPropIt->GetName();
      const bool bIsEqualProperty = FindEqualFieldName(PropertyChangedEvent, FieldName);
      if (FCameraViewDataBundle* ViewDataBundle = CameraBehaviorPropIt->ContainerPtrToValuePtr<FCameraViewDataBundle>(&CameraBehavior)
        ; nullptr != ViewDataBundle && bIsEqualProperty)
      {
        ViewDataBundle->Refresh();
        
        Super::PostEditChangeChainProperty(PropertyChangedEvent);
        return;
      }
    }
  }
  
  Super::PostEditChangeChainProperty(PropertyChangedEvent);
}

bool UCameraBehaviorDataBundleAsset::FindEqualFieldName(FPropertyChangedChainEvent& PropertyChangedEvent, const FString& FieldName) const
{
  auto* ChainNode = PropertyChangedEvent.PropertyChain.GetTail();
  while (ChainNode != nullptr)
  {
    if (const auto* ChainNodeValue = ChainNode->GetValue())
    {
      const FString PropertyName = ChainNodeValue->GetName();
      if (PropertyName.Equals(FieldName))
      {
        return true;
      }
    }
    
    ChainNode = ChainNode->GetPrevNode();
  }

  return false;
}
  • 위의 DataAsset 클래스는 ViewDataBundle이라는 구조체를 들고 있다
  • ViewDataOffset에 변경이 있거나, 변경이 있는 에셋의 이름이 ViewDataAsset이라고 판단되는 경우, ViewDataBundle에서 Refresh 함수를 호출한다
USTRUCT(BlueprintType)
struct FCameraViewDataBundle
{
  GENERATED_BODY()

  // ...
  
#if WITH_EDITOR
  // 번들의 ViewData가 변경되는 경우, LoadedData를 변경하기 위해 사용하는 함수
  void Refresh();

private:
  void OnUpdateViewData();
#endif
  
public:	
  UPROPERTY(EditAnywhere, Category=DataAsset, BlueprintReadOnly, meta=(ShowOnlyInnerProperties))
  TObjectPtr<class UCameraViewDataAsset> ViewDataAsset = nullptr;

  UPROPERTY(VisibleAnywhere, Category=DataAsset, BlueprintReadOnly)
  FCameraViewData LoadedViewData;
  
  UPROPERTY(EditAnywhere, Category=Offset, BlueprintReadOnly)
  FCameraViewData ViewDataOffset;

  // ...
}

#if WITH_EDITOR
void FCameraViewDataBundle::Refresh()
{
  if (IsValid(ViewDataAsset))
  {
    if (!RefreshHandle.IsValid())
    {
      // 해제 시 할당된 이벤트 제거하기 위해 포인터 버퍼 저장
      BufferPtr = ViewDataAsset;
      RefreshHandle = ViewDataAsset->OnViewDataChanged.AddRaw(this, &FCameraViewDataBundle::OnUpdateViewData);
    }
    
    OnUpdateViewData();
  }
  // 핸들에 람다식이 할당되어 있다면 해제한다
  else if (RefreshHandle.IsValid() && IsValid(BufferPtr))
  {
    BufferPtr->OnViewDataChanged.Remove(RefreshHandle);
    RefreshHandle.Reset();
  }
}

void FCameraViewDataBundle::OnUpdateViewData()
{
  if (IsValid(ViewDataAsset))
  {
    // 에셋 데이터로부터 보여줄 데이터에 로드 및 오프셋과 더해 최종 값 연산
    LoadedViewData = ViewDataAsset->CameraView;
    ViewData = LoadedViewData + ViewDataOffset;
  }
}
#endif
  • Refresh는 최종 값이 ViewData를 업데이트하기 위해 OnUpdateViewData를 호출한다
  • 또 Base가 되는 ViewDataAsset의 값이 변경되는 타이밍을 잡기 위해 Delegate 할당도 한다
  • 이 때 할당된 Delegate는 OnUpdateViewData로, ViewData를 다시 연산하는 함수다
UCLASS(BlueprintType)
class UCameraViewDataAsset : public UDataAsset
{
  GENERATED_BODY()

#if WITH_EDITOR
public:
  virtual void PostEditChangeProperty(FPropertyChangedEvent& PropertyChangedEvent) override;
#endif
  
public:
  UPROPERTY(EditAnywhere, meta=(ShowOnlyInnerProperties))
  FCameraViewData CameraView;
  
#if WITH_EDITORONLY_DATA
  FOnCameraViewDataAssetChanged OnViewDataChanged; 
#endif
};

#if WITH_EDITOR
void UCameraViewDataAsset::PostEditChangeProperty(FPropertyChangedEvent& PropertyChangedEvent)
{
  Super::PostEditChangeProperty(PropertyChangedEvent);

  if (OnViewDataChanged.IsBound())
  {
    OnViewDataChanged.Broadcast();	
  }
}
#endif
  • 카메라 데이터의 값 변경이 발생하면 할당한 Delegate를 실행한다
    • 이 카메라 데이터를 사용하고 있는 DataAsset에서 연산을 다시 하는 역할이다

다시 카메라 시스템을 만든다면

  • 기본적으로 A <-> B State를 2개만 두고 전환시킬 것 같다
  • 이렇게 하면 A -> B로 전환할 때
    • 전환될 값을 B state에 미리 할당한다
    • Blend는 AnimGraph든 Transition을 통해서 하고
  • 메시지 시스템으로 카메라 데이터 적용 및 해제
    • 메서드와 변수를 직접 접근하고 노출하다보니 코드간의 의존성이 높아졌다
    • GameplayMessage Plugin을 카메라 시스템 구현 후 적용했다
    • 우선순위가 낮아 카메라 시스템에 GmaeplayMessage를 적용하지 못했지만, 도입했다면 더 깔끔하게 만들 수 있었을 것 같다