wan의 개발일기

RoomCraft #19. 있는 척했던 기능 만들기 — 1인칭 시점 본문

1인 게임개발 활동/가구배치 시뮬레이터

RoomCraft #19. 있는 척했던 기능 만들기 — 1인칭 시점

wan_ 2026. 7. 9. 10:56
소스코드 → github.com/Jywan/RoomCraft  |  플레이 → jywan.github.io/RoomCraft

들어가며

README를 다시 읽다가 뜨끔했다. "주요 기능" 목록에 "탑뷰 ↔ 3D 자유시점 ↔ 1인칭 뷰"라고 적혀있었는데, 정작 CameraControllerCameraMode enum엔 TopView, FreeView 둘뿐이었다. 1인칭은 있는 척만 하고 실제로는 없었던 것. 이번 편에서 진짜로 만들었다.


시작 위치를 어디로 잡을까

제일 먼저 걸린 문제는 "1인칭으로 전환했을 때 카메라를 어디에 놓을 것인가"였다. 제일 쉬운 방법은 방 꼭짓점들의 평균(중심점)을 쓰는 건데, 이러면 L자형이나 지난번 만든 오목한 커스텀 방에서 그 중심점이 방 (파인 부분)에 떨어질 수 있다. 방 안에서 시작해야 할 카메라가 벽 밖에서 시작하는 꼴.

대신 이미 갖고 있던 도구를 재활용했다. 바닥을 만들 때 Ear Clipping으로 삼각분할을 해뒀으니, 그 삼각형 중 아무거나 하나를 골라 세 꼭짓점의 평균을 쓰면 된다. 삼각형의 무게중심은 어떤 모양의 삼각형이든 항상 그 삼각형 내부에 있다 — 그래서 방이 아무리 복잡한 모양이어도 결과값은 항상 바닥 위의 유효한 한 점이 된다.

// RoomBuilder.cs
public Vector3 GetFirstPersonSpawnPoint()
{
    if (currentRoomData == null) return Vector3.zero;

    List<Vector2> outline = currentRoomData.GetVertices();
    List<int> triangles = PolygonUtils.Triangulate(outline);

    Vector2 a = outline[triangles[0]];
    Vector2 b = outline[triangles[1]];
    Vector2 c = outline[triangles[2]];
    Vector2 centroid = (a + b + c) / 3f;

    return new Vector3(centroid.x, EyeHeight, centroid.y);
}

WASD로 걸어 다니기

기존 탑뷰/자유시점은 둘 다 targetPoint를 중심으로 카메라가 공전하는 방식이라 "이동"이라는 개념 자체가 없었다. 1인칭은 완전히 새로운 조작 방식이 필요했다 — WASD로 걷고, 마우스 드래그로 시점을 돌리는 흔한 1인칭 조작.

private void HandleFirstPersonInput()
{
    // 좌클릭 드래그로 시점 회전 (yaw/pitch)
    if (fpDragging)
    {
        Vector3 delta = Input.mousePosition - fpLastMousePosition;
        fpYaw += delta.x * lookSpeed * 0.1f;
        fpPitch -= delta.y * lookSpeed * 0.1f;
        fpPitch = Mathf.Clamp(fpPitch, -80f, 80f);
        transform.rotation = Quaternion.Euler(fpPitch, fpYaw, 0f);
    }

    // WASD 이동, Y는 고정해서 눈높이 유지
    Vector3 move = Vector3.zero;
    if (Input.GetKey(KeyCode.W)) move += transform.forward;
    if (Input.GetKey(KeyCode.S)) move -= transform.forward;
    if (Input.GetKey(KeyCode.A)) move -= transform.right;
    if (Input.GetKey(KeyCode.D)) move += transform.right;
    move.y = 0f;
}

탑뷰 ↔ 자유시점 ↔ 1인칭은 기존에 있던 T키 토글에 그대로 얹었다. 원래 2단 토글이던 ToggleView()를 3단 순환으로 바꿨다.


둘러보기 전용으로

1인칭에서도 가구를 선택하고 옮길 수 있게 할지 고민했는데, 마우스 드래그가 "시점 회전"과 "가구 드래그" 둘 다에 쓰이는 조작이라 겹치면 헷갈릴 게 뻔했다. 1인칭은 배치 결과를 둘러보는 용도로만 쓰고, 편집은 다시 탑뷰/자유시점으로 돌아가서 하는 걸로 정리했다.

// FurnitureInteraction.cs
private void Update()
{
    if (cameraController != null && cameraController.GetCurrentMode() == CameraMode.FirstPerson)
        return;

    HandleSelection();
    HandleDrag();
    HandleRotation();
    HandleDelete();
}

1인칭으로 들어가는 순간 기존에 선택되어 있던 가구도 강제로 선택 해제하도록 했다. 안 그러면 1인칭으로 걸어 다니는 동안에도 툴바에 회전/삭제 버튼이 떠 있는 상태로 남는다.


벽은 막고 가구는 통과 — CharacterController

처음엔 transform.position에 이동값을 그냥 더하는 방식으로 짰다. 당연히 벽을 그대로 뚫고 지나갔다. Unity의 CharacterController 컴포넌트를 카메라에 붙이고, 이동을 characterController.Move()로 바꿨다.

Vector3 delta = move.normalized * (moveSpeed * Time.deltaTime);
if (characterController != null)
    characterController.Move(delta);
else
    transform.position += delta;

재밌는 건 가구는 여전히 통과된다는 점이다. 가구 콜라이더가 선택/드래그 감지를 위해 isTrigger = true로 되어있는데, CharacterController는 트리거 콜라이더랑 충돌하지 않는다. 벽(일반 콜라이더)만 막고 가구는 통과되는 지금 상태가 결과적으로 딱 원하던 동작이라 그대로 뒀다.

1인칭 시점으로 본 방 구석 — 이제 진짜로 걸어 다닐 수 있다

삽질 기록

1인칭에서 영원히 못 빠져나옴

3단 순환으로 바꾸면서 조건 순서를 잘못 짰다.

else if (currentMode == CameraMode.FirstPerson)
    SwitchMode(CameraMode.FirstPerson);  // 자기 자신으로 전환

SwitchMode() 맨 앞줄이 "같은 모드면 무시"라서, 1인칭 상태에서 이 코드가 실행되면 아무 일도 안 일어난다. 즉 한번 1인칭에 들어가면 T키를 눌러도 계속 1인칭에 갇힌다. FreeView일 때 FirstPerson으로 가야 하는 걸 FirstPerson일 때 FirstPerson으로 가게 잘못 짠 것 — 조건 순서만 바로잡았다.

CharacterController 넣었는데 그래도 벽을 뚫음

코드도 고쳤고 컴파일도 되는데 여전히 벽을 통과했다. 씬 파일을 열어서 확인해보니 CharacterController 컴포넌트 자체가 Main Camera에 없었다 — 코드에 필드만 추가해뒀지, 실제로 Unity 에디터에서 컴포넌트를 추가하고 인스펙터 슬롯에 연결하는 걸 깜빡했던 것. characterController != null 체크가 조용히 폴백 경로(충돌 없는 예전 방식)로 빠지고 있었다.

코드가 컴파일된다고 기능이 다 붙은 게 아니다 — 특히 인스펙터에서 손으로 연결해야 하는 참조는 코드 리뷰만으론 못 잡는다.

Rider가 잡아준 것들

이번엔 라이더의 정적 분석 경고를 하나씩 따라가봤다. Camera.mainStart()에서 캐싱(FurnitureInteraction에 이미 있던 패턴 재사용), 씬에 항상 존재하는 EventSystem.current의 불필요한 null 체크 제거, move.normalized * moveSpeed * Time.deltaTime처럼 벡터-스칼라 곱셈을 연쇄로 하던 걸 스칼라끼리 먼저 곱하고 벡터엔 한 번만 곱하도록(move.normalized * (moveSpeed * Time.deltaTime)) 순서를 바꿨다. 다만 자주 호출 안 되는 코드(모드 전환 시 한 번뿐인 StartTransition()의 null 체크 등)까지 전부 캐싱하지는 않았다 — 경고가 뜬다고 다 고칠 필요는 없고, 매 프레임 도는 코드인지가 기준이었다.


정리

기능핵심 API / 구조
1인칭 시작 위치RoomBuilder.GetFirstPersonSpawnPoint() — 삼각형 무게중심
WASD 이동 + 마우스룩CameraController.HandleFirstPersonInput()
3단 시점 순환ToggleView() — TopView → FreeView → FirstPerson
가구 상호작용 차단FurnitureInteraction.Update() 모드 가드 + DeselectAll()
벽 충돌CharacterController.Move() (가구는 트리거라 통과)
다음 단계Phase 6-2 모바일 배포 (미정), WebGL 한글 입력 문제