F5 키를 누른다. 3인칭에서 1인칭으로 전환된다.
마우스 좌클릭(활을 조준)을 누른다. 짧은 시간이 지나면 카메라가 갑자기 3인칭으로 전환되어 버린다.
분명 1인칭이었는데, 왜 3인칭 위치로 돌아간 걸까?
다른 캐릭터로 바꿔본다. 이번엔 1인칭에서 3인칭으로 복귀가 안 된다.
카메라가 캐릭터 내부에 박혀서 움직이지 않는다.
멀티플레이 테스트를 시작한다. 내 화면에서는 정상인데, 다른 플레이어 화면에서 내 캐릭터가 엉뚱한 방향을 바라보고 있다.
시점 전환은 단순해 보이지만, 상태 관리, Tick 최적화, 네트워크 동기화가 복잡하게 얽힌 시스템이다.
이 글은 1인칭/3인칭 카메라 전환 시스템을 구현하면서 마주친 다섯 가지 실제 문제와 그 해결 과정을 기록한다.
문제 1: 1인칭에서 줌 효과 후 3인칭으로 점프
증상
메르시(Merci) 캐릭터로 1인칭 시점에서 활을 조준하면,
줌이 끝날 때 카메라가 갑자기 3인칭 위치로 이동한다.
재현 조건은 다음과 같다.
- F5 키로 1인칭 시점 전환
- 마우스 왼쪽 버튼으로 활 조준 (줌 타임라인 실행)
- 조준 해제 시 카메라가 뒤로 “점프”
에러 로그는 없다. 논리적 버그다.
원인 분석
메르시의 UpdateZoom() 함수가 줌 타임라인 종료 시...
SpringArmComp->TargetArmLength를 하드코딩된 3인칭 값(350.0f)으로 리셋하고 있었다.
// 문제가 있는 코드
void AGS_Merci::UpdateZoom(float Alpha)
{
// Alpha가 0이 되면 TargetArmLength가 350으로 설정됨
float TargetArmLength = FMath::Lerp(350.0f, 180.0f, Alpha);
SpringArmComp->TargetArmLength = TargetArmLength;
}
핵심 실수는 줌 로직이 현재 시점을 확인하지 않은 것이다.
1인칭에서는 SpringArm 길이가 0이어야 하는데, 줌 함수가 이를 덮어썼다.
해결 과정
// 수정된 코드
void AGS_Merci::UpdateZoom(float Alpha)
{
if (!SpringArmComp || !CameraComp)
{
return;
}
// FOV 조정은 1인칭/3인칭 공통으로 적용
float BaseFOV = IsFirstPerson() ? FirstPersonFOV : SavedTPSFOV;
float TargetFOV = FMath::Lerp(BaseFOV, BaseFOV - 20.0f, Alpha);
CameraComp->SetFieldOfView(TargetFOV);
// 핵심: 1인칭에서는 카메라 거리 조정을 스킵
if (IsFirstPerson())
{
return;
}
// 3인칭에서만 카메라 거리 및 오프셋 조정
float TargetArmLength = FMath::Lerp(350.0f, 180.0f, Alpha);
SpringArmComp->TargetArmLength = TargetArmLength;
}
| 변경 전 | 변경 후 |
| 시점 확인 없이 ArmLength 설정 | IsFirstPerson() 체크 후 조기 리턴 |
| FOV와 ArmLength를 함께 처리 | FOV는 공통, ArmLength는 3인칭 전용 |
“카메라 관련 함수는 항상 현재 시점 상태를 먼저 확인해야 한다. 이 원칙을 지키지 않으면 예측 불가능한 동작이 발생한다.”
문제 2: 특정 캐릭터에서 3인칭 복귀 실패
증상
찬(Chan), 드라카(Drakhar) 캐릭터에서 F5로 1인칭 전환 후 다시 3인칭으로 돌아오면 카메라가 캐릭터 내부에 고정된다. ArmLength가 0인 상태로 유지된다. 메르시, 아레스에서는 정상적으로 3인칭 복귀가 된다.
원인 분석
시점 전환 보간이 Tick() 함수에서 처리되는데, 캐릭터가 최적화를 위해 Tick을 비활성화하고 있었다.
[GS_Player::TogglePerspective]
↓
bIsPerspectiveTransitioning = true
SetActorTickEnabled(true) ← 부모 클래스에서 Tick 활성화
↓
[다음 프레임 - GS_Chan::Tick]
↓
??? ← 찬은 bCanEverTick=false라서 Tick 자체가 안 돌아감
각 캐릭터의 Tick 설정을 정리하면 다음과 같다.
| 캐릭터 | bCanEverTick | bStartWithTickEnabled | 결과 |
| 메르시 | true | true | ✅ 정상 |
| 아레스 | true | true | ✅ 정상 |
| 찬 | false | - | ❌ Tick 불가 |
| 드라카 | true | true | ⚠️ 비행 로직이 Tick 강제 종료 |
해결 과정
찬(Chan) 수정:
// 문제가 있는 코드
AGS_Chan::AGS_Chan()
{
PrimaryActorTick.bCanEverTick = false;
}
// 수정된 코드
AGS_Chan::AGS_Chan()
{
PrimaryActorTick.bCanEverTick = true;
PrimaryActorTick.bStartWithTickEnabled = false;
}
드라카(Drakhar) 수정:
// 문제가 있는 코드
void AGS_Drakhar::Tick(float DeltaTime)
{
Super::Tick(DeltaTime);
if (!bIsFlying)
{
SetActorTickEnabled(false);
}
}
// 수정된 코드
void AGS_Drakhar::Tick(float DeltaTime)
{
Super::Tick(DeltaTime);
if (!bIsFlying)
{
if (!bIsPerspectiveTransitioning)
{
SetActorTickEnabled(false);
}
}
}
bCanEverTick과 bStartWithTickEnabled의 차이를 명확히 이해해야 한다.
bCanEverTick = false로 설정하면 SetActorTickEnabled(true)를 호출해도 Tick이 실행되지 않는다.
반면 bStartWithTickEnabled = false는 Tick 실행 자체는 가능하되, 게임 시작 시에는 비활성화된 상태로 시작한다.
“상속 구조에서 부모 클래스의 Tick 로직이 자식 클래스의 최적화 코드에 의해 방해받을 수 있다. 자식 클래스에서 Tick을 끄기 전에 부모의 진행 중인 작업을 확인해야 한다.”
문제 3: 멀티플레이에서 캐릭터 회전 불일치
증상
다른 플레이어 화면에서 내 캐릭터가 바라보는 방향이 실제와 다르게 보인다.
플레이어 A가 1인칭으로 전환하면, 플레이어 B 화면에서 A의 캐릭터가 여전히 이동 방향으로 회전한다.
원인 분석
bUseControllerRotationYaw(캐릭터가 컨트롤러 방향을 따라 회전할지 결정하는 플래그) 설정이
클라이언트에서만 변경되고 서버에 동기화되지 않았다.
// 클라이언트 전용 변경 (다른 플레이어에게 반영 안 됨)
void AGS_Player::TogglePerspective()
{
if (bIsFirstPerson)
{
bUseControllerRotationYaw = true;
}
}
Unreal Engine의 캐릭터 회전 시스템에서 bUseControllerRotationYaw = true면 캐릭터가 카메라 방향을 바라보고, false면 이동 방향을 바라본다. 서버가 이 값을 모르면 다른 클라이언트에게 잘못된 회전 정보가 전달된다.
해결 과정
// GS_Player.h
UFUNCTION(Server, Reliable)
void Server_SetPerspectiveState(bool bFirstPerson);
// GS_Player.cpp
void AGS_Player::TogglePerspective()
{
bIsFirstPerson = !bIsFirstPerson;
// 서버에 시점 상태 동기화
Server_SetPerspectiveState(bIsFirstPerson);
if (bIsFirstPerson)
{
bUseControllerRotationYaw = true;
}
else
{
bUseControllerRotationYaw = bSavedUseControllerRotationYaw;
}
}
void AGS_Player::Server_SetPerspectiveState_Implementation(bool bFirstPerson)
{
bUseControllerRotationYaw = bFirstPerson;
}
동기화 흐름은 다음과 같다.
클라이언트 A에서 F5를 누르면 TogglePerspective()가 호출되고, Server_SetPerspectiveState(true) RPC가 서버로 전송된다.
서버에서 bUseControllerRotationYaw = true가 설정되고, 이 값이 다른 클라이언트들에게 복제된다.
“시각적 효과만 영향을 주는 것(FOV, 카메라 거리)은 클라이언트 전용으로 처리해도 된다. 하지만 다른 플레이어에게 보여야 하는 것(회전, 포즈)은 서버 동기화가 필수다.”
문제 4: 디더링 효과로 인한 캐릭터 투명화
증상
1인칭 전환 시 캐릭터 메시가 투명하게 보인다.
머티리얼 구조를 보면 디더링 노드 구조가 확인된다.

원인 분석
디더링(Dithering, 카메라가 캐릭터에 가까워질 때 메시를 점진적으로 투명하게 만드는 기법)은 3인칭에서 벽에 막혔을 때 캐릭터가 안 보이는 문제를 해결하기 위한 기법이다.
문제는 1인칭에서 카메라가 캐릭터 내부에 있으므로 디더링이 캐릭터를 투명하게 만든다는 것이다.
머티리얼 그래프를 보면 DitherDistance 파라미터가 MF_Dither 함수를 거쳐 DitherTemporalAA로 연결된다.
거리값에 따라 알파 임계값이 조절되는 구조다.

해결 과정
void AGS_Player::UpdateCharacterDither(bool bEnabled)
{
// 1인칭: 거리 0 (디더링 비활성화)
// 3인칭: 기본 거리 (디더링 활성화)
float TargetDistance = bEnabled ? DefaultDitherDistance : 0.0f;
TArray<UMeshComponent*> MeshComps;
GetComponents<UMeshComponent>(MeshComps);
// 부착된 액터(무기 등)의 메시도 포함
TArray<AActor*> AttachedActors;
GetAttachedActors(AttachedActors);
for (AActor* AttachedActor : AttachedActors)
{
if (AttachedActor)
{
TArray<UMeshComponent*> AttachedMeshComps;
AttachedActor->GetComponents<UMeshComponent>(AttachedMeshComps);
MeshComps.Append(AttachedMeshComps);
}
}
for (UMeshComponent* MeshComp : MeshComps)
{
if (MeshComp)
{
MeshComp->SetScalarParameterValueOnMaterials(DitherParamName, TargetDistance);
}
}
}
void AGS_Player::TogglePerspective()
{
if (bIsFirstPerson)
{
UpdateCharacterDither(false);
}
else
{
UpdateCharacterDither(true);
}
}
무기나 장비 등 부착된 액터의 메시도 함께 처리해야 한다.
그렇지 않으면 캐릭터는 보이는데 무기만 투명해지는 현상이 발생한다.


문제 5: 메르시 줌 FOV 계산 오류
증상
1인칭에서 조준 시 화면이 갑자기 확대되지 않고 오히려 축소되는 느낌이 든다.
원인 분석
// 잘못된 기준값 사용
float TargetFOV = FMath::Lerp(SavedTPSFOV, SavedTPSFOV - 20.0f, Alpha);
// 1인칭 FOV가 75인데, 줌 시 90→70으로 계산되어 화면이 넓어짐
항상 3인칭 FOV(90)를 기준으로 계산했기 때문에, 1인칭 FOV(75)에서 줌을 시작하면 오히려 시야가 넓어지는 역효과가 발생했다.
해결 과정
// 현재 시점에 맞는 기준값 사용
float BaseFOV = IsFirstPerson() ? FirstPersonFOV : SavedTPSFOV;
float TargetFOV = FMath::Lerp(BaseFOV, BaseFOV - 20.0f, Alpha);
// 1인칭: 75→55, 3인칭: 90→70 으로 올바르게 계산
정리
시점 전환 시스템 체크리스트
- [ ] 카메라 관련 함수에서 현재 시점 상태 확인
- [ ] 자식 클래스 Tick 설정이 부모 로직을 방해하지 않는지 확인
- [ ] 다른 플레이어에게 보여야 하는 값은 Server RPC로 동기화
- [ ] 디더링 등 거리 기반 머티리얼 효과 처리
- [ ] FOV 계산 시 현재 시점 기준값 사용
Tick 최적화 패턴
// 생성자
PrimaryActorTick.bCanEverTick = true;
PrimaryActorTick.bStartWithTickEnabled = false;
// 필요할 때만 활성화
SetActorTickEnabled(true);
// 작업 완료 후 비활성화
SetActorTickEnabled(false);
멀티플레이 동기화 기준
| 항목 | 동기화 | 필요 방법 |
| 카메라 FOV | ❌ | 로컬 전용 |
| 카메라 거리 | ❌ | 로컬 전용 |
| 캐릭터 회전 | ✅ | Server RPC |
| 애니메이션 상태 | ✅ | Replicated 변수 |
관련 파일 요약
| 파일 | 역할 | 주요 수정 |
| GS_Player.cpp | 시점 전환 기반 클래스 | TogglePerspective(), Server RPC 추가 |
| GS_Merci.cpp | 메르시 줌 로직 | UpdateZoom() 시점 분기 추가 |
| GS_Drakhar.cpp | 드라카 비행 카메라 | Tick 종료 조건에 보간 체크 추가 |
| GS_Chan.cpp | 찬 Tick 설정 | bCanEverTick 활성화 |

'Dev. > UE 언리얼 엔진' 카테고리의 다른 글
| [NodeToCode + 로컬 AI 가이드] 블루프린트를 N초 만에 C++로? (0) | 2026.01.27 |
|---|---|
| 언리얼 엔진 GAS(Gameplay Ability System) (1) | 2026.01.21 |
| [GAS] Unreal Engine 데미지 팝업 시스템 개선: 타격감을 위한 Dev Log (0) | 2026.01.16 |
| [GAS] 1월 초 구현한 시스템 정리 (0) | 2026.01.16 |
| [GAS] Unreal Engine 죽음 시네마틱 카메라 시스템 (2) | 2026.01.14 |