[UE5] ProjectWorldToScreen

kim skye·2025년 8월 20일

Unreal Engine

목록 보기
1/3
post-thumbnail

개요

UGameplayStatics::ProjectWorldToScreen 함수는 언리얼 엔진에서 3D 월드 좌표를 현재 플레이어의 2D 뷰포트(스크린) 좌표로 변환하는 함수이다.

  • 예전에 만든 인디케이터 UI 코드를 보고있다가 든 의문점을 해소하기 위해 작성한 글
  • ProjectWorldToScreen 는 어떻게 동작하는걸까?
  • 결론: 행렬곱셈을 이용해서 동작한다!!

이 글은 언리얼 엔진 5.6 기준으로 작성되었습니다.


기본 사용법

// UGameplayStatics.h 
static bool ProjectWorldToScreen(
    const APlayerController* Player,
    const FVector& WorldPosition,
    FVector2D& ScreenPosition,
    bool bPlayerViewportRelative
);

간단 예시:

  • Actor의 월드좌표를 스크린 좌표(픽셀)로 변환하는 함수
  bool GetActorScreenPosition(AActor* Actor, FVector2D& OutScreenPos)
  {
      if (!Actor) return false;

      UWorld* World = Actor->GetWorld();
      if (!World) return false;

      // 플레이어 컨트롤러 가져오기
      APlayerController* PC = World->GetFirstPlayerController();
      if (!PC) return false;

      // 월드 좌표 → 스크린 좌표 변환
      const FVector WorldPos = Actor->GetActorLocation();
      return UGameplayStatics::ProjectWorldToScreen(PC, WorldPos, OutScreenPos);
  }
  • 참고) bPlayerViewportRelative = false가 기본값이다.
  FVector2D ScreenPos;
if (GetActorScreenPosition(SomeActor, ScreenPos))
{
    UE_LOG(LogTemp, Log, TEXT("액터가 스크린에 존재함");
}
else
{
    UE_LOG(LogTemp, Warning, TEXT("액터는 스크린 상에 존재하지 않음"));
}
  • 이런식으로, bool 값을 받아와 판단할 수 있다.

함수 내부 흐름

먼저, 엔진 코드를 참고하면 ProjectWorldToScreen() 함수의 흐름은 이렇다.

1. 입력받은 PC에서 LocalPlayer를 가져온다.

ULocalPlayer* const LP = Player ? Player->GetLocalPlayer() : nullptr;
  • 로컬 플레이어와 연결된 ViewportClient가 존재해야 스크린 좌표 계산이 가능하고, 없으면 if문 내부에서 걸러진다.
  • LocalPlayer를 가져와야 분할화면 상황(멀티로컬)에서 구분 가능

2. ProjectionData를 가져온다.

FSceneViewProjectionData ProjectionData;
if (LP->GetProjectionData(LP->ViewportClient->Viewport, /*out*/ ProjectionData))
  • 현재 카메라 뷰와 관련된 투영 정보를 가져온다고 할 수 있다.
  • 이때 LocalPlayer 내부 GetProjectionData()함수를 이용해 값을 채운다.

GetProjectionData() 내부 작업
1. ULocalPlayer::GetProjectionData 내부에서

ProjectionData.ViewOrigin = StereoViewLocation; // 카메라/눈 위치
ProjectionData.ViewRotationMatrix =
    FInverseRotationMatrix(ViewInfo.Rotation) *
    FMatrix(
      FPlane(0,0,1,0),  // UE 좌표계를 뷰공간 축으로 재배치
      FPlane(1,0,0,0),
      FPlane(0,1,0,0),
      FPlane(0,0,0,1));
  • FInverseRotationMatrix는 카메라 회전의 역행렬을 곱해 월드를 카메라 기준으로 회전 시킨다.
  • 뒤의 고정 행렬은 UE 좌표계를 뷰공간에 맞추는 작업이라고 생각하면 된다.

2. FMinimalViewInfo::CalculateProjectionMatrixGivenView()

  • LocalPlayer의 GetProjectionData 마지막 부분에서 호출되는 함수
  • 원래 카메라 종횡비(ViewInfo.AspectRatio) 를 크롭 반영 종횡비로 조정하고, 유효 영역(ConstrainedViewRect, 쉽게 말하면 사각형)을 산출한다.
  • 확정된 뷰 사각형과 ViewInfo 등을 사용해 Reversed-Z 투영 행렬을 만든다...!

요약

  • 로컬 플레이어 뷰포트 크기 + 카메라 정보를 가져와서
  • 월드 좌표 → 카메라 좌표 변환 뷰 행렬(View Matrix) 수행
  • 원근법 반영, 3D 공간을 2D 평면으로 투영 행렬(Projection Matrix) 수행

3. ViewProjection 행렬 계산

FMatrix const ViewProjectionMatrix = ProjectionData.ComputeViewProjectionMatrix();
  • 카메라의 ViewMatrix(월드→뷰 공간)와 ProjectionMatrix(뷰 공간→클립 공간)를 합쳐서 ViewProjectionMatrix를 만든다.

4. 월드 → 스크린 변환

bool bResult = FSceneView::ProjectWorldToScreen(WorldPosition,
    ProjectionData.GetConstrainedViewRect(), ViewProjectionMatrix, ScreenPosition);
  • WorldPosition을 입력받아 스크린 좌표(ScreenPosition)로 변환한다.
  • FSceneView::ProjectWorldToScreen 는 내부적으로
    FVector4 ClipSpace = ViewProjectionMatrix.TransformFVector4(WorldPos)
  • 월드 좌표(WorldPos)에 ViewProjectionMatrix (뷰 행렬 × 투영 행렬)를 곱해 클립 공간 좌표를 만들고
  • 클립 좌표를 𝑊 로 나누어 NDC(-1~1) 범위로 정규화
  • [−1,1] 범위의 NDC를 [0,1] 범위로 변환
  • 정규화 좌표 → 픽셀 좌표

5. 뷰포트 상대 좌표 보정

if (bPlayerViewportRelative)
{
    ScreenPosition -= FVector2D(ProjectionData.GetConstrainedViewRect().Min);
}
  • 좌표 원점을 어디로 잡을 것인지 결정하는 부분이다.
  • 즉, 이 값이 true일 경우 스크린 좌표는 전체 화면이 아닌 플레이어 뷰포트 내부의 상대 좌표로 보정된다.

함수 흐름 요약

간단하게 보자면
월드 좌표를 → 카메라 기준으로 보기 → 투영해서 납작하게 만들기 → 정규화 → 픽셀 좌표 변환한다 고 할 수 있다.

밑은 실제 행렬 적용 과정이다.


ProjectWorldToScreen 에서 사용하는 행렬 곱셈(좌표 변환) 과정

  1. 월드 좌표
    어떤 오브젝트의 위치가 P_world 라고 하자.
    Pworld=[xyz1]\mathbf P_{\text{world}}= \begin{bmatrix} x\\y\\z\\1 \end{bmatrix}
  1. 뷰 (View Matrix)
    카메라 위치를 CC (=ProjectionData.ViewOrigin), 카메라 회전을 RR 라고 두면
    Pview  =  R1(PworldC)\boxed{P_{view} \;=\; R^{-1}\, \big(P_{world}-C\big)}
  • R^{-1}(역회전)을 적용해 카메라가 원점에 있고 정면을 보는 좌표계로 옮겨준다.
    • UE 내부에선 ViewRotationMatrix-ViewOrigin을 합쳐 ViewMatrix를 만드는 걸로 이 작업을 수행
  1. 투영 (Projection Matrix)
    투영 행렬 PP (=ProjectionData.ProjectionMatrix, FOV/종횡비/near/far 반영)를 곱한다.
    Pclip  =  P    [Pview1]    =    [xcyczcwc]\boxed{P_{clip} \;=\; P \; \cdot \; \begin{bmatrix} P_{view} \\ 1 \end{bmatrix}} \;\;=\;\; \begin{bmatrix} x_c \\ y_c \\ z_c \\ w_c \end{bmatrix}
  • 3D 공간을 2D 평면에 투영함 (멀리 있는 건 작게, 가까운 건 크게)
  1. 퍼스펙티브 나눗셈 → NDC
    xndc=xcwc,    yndc=ycwc,    zndc=zcwc\boxed{ x_{ndc}=\frac{x_c}{w_c},\;\; y_{ndc}=\frac{y_c}{w_c},\;\; z_{ndc}=\frac{z_c}{w_c} }
  • wcw_{c} 는 카메라까지의 거리 정보라고 생각할 수 있다.
  • 결과가 [1,1][-1,1] 범위에 있으면 화면 안, 아니면 클리핑 대상으로 판단
    • UE는 깊이에 Reversed-Z를 쓰지만(멀리 있을수록 작은 z), z는 독립적으로 깊이에 계산되는 값이므로 화면상 x·y 변환식에는 영향은 없다.

출처: OpenGL Projection Matrix

  • 왼쪽그림이 눈으로 본 공간, 카메라는 원점에 위치해 -Z축을 향해 있음 (오른손 좌표계)
  • 오른쪽 그림이 NDC, 정규화된 정육면체 범위를 보여준다. +Z축이 전방 (왼손 좌표계)
  • 이 과정에서:
    X좌표: [l, r] → [-1, 1]
    Y좌표: [b, t] → [-1, 1]
    Z좌표: [-n, -f] → [-1, 1] (NDC, 부호 반전 포함)

Reversed-Z
UE의 Reversed-Z는 멀리 있는 점일수록 z가 작아지고, 가까울수록 z=1에 가까워진다는 특성이다.
추가적으로,

  • FSceneView::ProjectWorldToScreen의 반환값(bool) 은 다음과 같이 계산된다.
    FPlane Result = ViewProjectionMatrix.TransformFVector4(FVector4(WorldPosition,1));
    bool bIsInsideView = Result.W > 0.0f; // 카메라 "앞"에 있는가?
    • 즉, bool은 zndcz_{ndc}가 아니라 W의 부호로 결정된다. (w >0이면 카메라 전방, w < 0이면 후방)
    • 이처럼 FSceneView::ProjectWorldToScreen는 전방 여부만을 반영한다.
  • 퍼스펙티브 나눗셈을 통해 얻은 zndcz_{ndc}
    • Z-buffer: 같은 화면 위치에 여러 물체가 있을때 -> 누가 더 앞에있는지를 결정한다.
    • 화면 클리핑: x,y가 범위[-1, 1]를 벗어나면 GPU에서 잘려서 보이지 않는다. (위의 함수는 범위체크를 하지 않음)

      즉, bool 값이 true 더라도 XY 범위와 z_ndc 범위까지 검사해야 진짜 화면 안에 보이는지를 알 수 있는 것이다.

  • 하지만 보통은 카메라 전방/후방 판단만으로도 보임/안보임 판정은 잘 되는 것 같다.
  1. NDC → 픽셀(Screen) 좌표
  • ConstrainedViewRect는 “최종” 뷰 사각형으로,
  • ConstrainedViewRect = [xmin,ymin,xmax,ymax][x_{min},y_{min},x_{max},y_{max}] 라고 하면
    xscreen  =  xmin+xndc+12(xmaxxmin)yscreen  =  ymin+1yndc2(ymaxymin)\begin{aligned} \boxed{x_{screen} \;=\; x_{min} + \frac{x_{ndc}+1}{2}\cdot (x_{max}-x_{min})} \\ \boxed{y_{screen} \;=\; y_{min} + \frac{1-y_{ndc}}{2}\cdot (y_{max}-y_{min})} \end{aligned}
  • 위 식으로 NDC(-1..1) 를 실제 픽셀 사각형에 매핑한다.
  • 참고: NDC (Normalized Device Coordinates)는 3D 좌표를 화면에 투영(projection)한 뒤, 정규화된 좌표계에 집어넣은 결과이다. 범위가 항상 -1 ~ +1 로 고정된다.

  • 뷰포트 전체가 아니라 유효 영역(ConstrainedViewRect) 기준으로 매핑됨
  • 좌표가 어긋나 보인다면 이 유효 영역 때문일 수 있음
  1. bPlayerViewportRelative == true 라면
    (x,y)(xscreen,yscreen)(xmin,ymin)\boxed{(x,y) \leftarrow (x_{screen},y_{screen}) - (x_{min},y_{min})}
  • 스플릿 스크린에서 해당 플레이어 뷰포트의 (0,0) 을 원점으로 맞춰준다.

bPlayerViewportRelative

  • 위에서 말했다싶이, 좌표 원점을 어디로 잡을 것인지 결정해 주는 변수이다.

  • bPlayerViewportRelative = false (기본값) 이면

    • 월드 좌표 → 스크린 좌표로 바꾸면, 결과가 모니터 전체 해상도 기준 픽셀 좌표로 나온다.
    • 즉, 화면 왼쪽 위 (0,0)은 모니터 전체의 (0,0).
    • 예) 1920x1080 해상도 모니터에서, 스크린 중앙은 (960, 540) 근처 좌표일 것이다.
  • bPlayerViewportRelative = true 이면

    • 각 플레이어 뷰포트의 좌상단을 원점 (0,0)으로 다시 맞춰준다. (분할 화면을 사용하는 경우를 예시로 들 수 있음)

    • 예를 들어 1920*1080 화면에서

      • 각자의 뷰포트 좌상단을 (0,0)으로 다시 설정하므로 플레이어B는 (960,0)을 (0,0)으로 사용한다.
      • Player B가 (1200,200)을 받았다면 → (1200-960, 200-0) = (240,200) 이런 식으로 자기 뷰포트 상대 좌표가 된다.
      • 분할 화면이 아니라면 전체 화면 = 로컬 플레이어 뷰포트이고, true/false가 차이가 거의 없기 때문에 사용할 일은 드물 것 같다.
    • 덧. It Takes Two 게임이 분할 화면을 사용한다.


마무리

  • ProjectWorldToScreen은 ProjectionData와 ConstrainedViewRect를 사용해 월드→스크린 투영을 수행하는 함수이다.
  • 행렬 곱셈을 이용해 좌표가 변환된다.
  • bPlayerViewportRelative = true면 결과 좌표는 플레이어 뷰포트 사각형 로컬 기준. 분할화면에 적합하고, 평상시에는 사용할 일이 거의 없을 듯 함.

참고 자료

profile
고수는 많다

0개의 댓글