이번 프로젝트에서는 게임 콘텐츠를 직접 수정하고 관리할 수 있는 런타임 기반 3D 관리자 시스템을 개발했다.
일반적인 게임 클라이언트와 달리 이 시스템은 플레이만 하는 용도가 아니었다. 기획자나 레벨 디자이너가 별도의 개발 작업 없이 3D 맵을 수정하고, 블록을 조합해 새로운 구조물을 만들고, 만들어진 구조물을 다시 재사용할 수 있어야 했다.
처음에는 단순한 3D 맵 편집 기능 정도로 생각했지만, 실제로 기능을 붙이다 보니 문제가 하나씩 늘어났다.
블록을 어떻게 선택하고 배치할 것인지, 여러 블록을 하나의 구조물처럼 저장할 것인지, 저장한 구조물을 UI에서 어떻게 보여줄 것인지, 런타임에 만들어진 구조물의 썸네일은 어떻게 만들 것인지까지 연결해서 생각해야 했다.
결과적으로 3D 레벨 편집기 -> 데이터 관리 -> 썸네일 생성 -> 관리자 UI -> 다시 3D 맵 배치까지 하나의 흐름으로 연결된 시스템을 구성했다.
관리자 시스템의 전체 구조

관리자 시스템에서 가장 중요하게 생각한 부분은 기능을 따로 만드는 것이 아니라 각각의 기능이 데이터 중심으로 연결되도록 만드는 것이었다.
기본 블록 데이터와 사용자가 생성한 블록 그룹 데이터를 기준으로 3D 편집기와 UI가 같은 데이터를 바라보도록 구성했다.
전체적인 흐름은 다음과 같다.
3D 레벨 편집 -> 블록 데이터 생성/수정 -> 블록 그룹 저장 -> 썸네일 생성 -> 관리자 UI 반영 -> UI에서 재배치
이 구조를 기준으로 크게 다음 기능을 개발했다.
- 3D 그리드 기반 레벨 편집
- 블록 선택, 설치, 삭제, 회전
- Undo / Redo
- 블록 그룹 생성 및 템플릿화
- 런타임 3D 썸네일 생성
- 블록 카테고리 및 팔레트 UI
- 동적 블록 그룹 UI
- UI -> 3D 드래그 앤 드롭
- 맵 데이터 저장 및 불러오기
- 데이터 검증 및 암호화
3D 레벨 편집 기능
관리자 시스템의 기본 기능은 3D 공간에서 블록을 배치하는 것이었다.
블록을 하나씩 배치하는 것만으로는 실제 레벨 제작에 사용하기 불편하기 때문에 마우스 피킹을 이용해 현재 커서가 위치한 공간을 확인하고, 해당 위치에 배치될 블록을 미리 보여주는 고스트 블록 프리뷰를 구현했다.
사용자는 블록을 선택한 뒤 원하는 위치를 클릭하는 방식으로 배치할 수 있고, 배치된 블록은 다시 선택해서 삭제하거나 회전할 수 있도록 했다.
블록 회전은 단축키를 이용해 90도 단위로 처리했다.
블록 선택 -> 마우스 위치 계산 -> 고스트 블록 표시 -> 클릭 -> 블록 배치
이미 배치되어 있는 블록을 다시 사용해야 하는 경우도 많았다.
그래서 G키를 이용해 기존 블록을 선택하면 해당 블록의 데이터를 가져와 현재 선택 블록으로 지정하는 스포이트 기능도 추가했다.
레벨을 제작하다 보면 같은 작업을 반복하는 경우가 많기 때문에 이런 작은 편의 기능도 실제 작업 효율에 꽤 큰 영향을 줬다.
Undo / Redo
3D 편집기에서 Undo / Redo는 기본적인 기능이지만 실제 구현에서는 단순히 Transform을 되돌리는 것만으로 해결하기 어려웠다.
블록의 생성, 삭제, 회전 등 편집 작업 자체를 하나의 데이터로 기록하고 이전 상태와 이후 상태를 관리하는 방식으로 구성했다.
이를 위해 두 개의 Stack<BlockHistoryData>를 사용했다.
Undo Stack <-> 현재 상태 <-> Redo Stack
새로운 편집 작업이 발생하면 Undo Stack에 기록하고, Undo를 실행하면 해당 데이터를 Redo Stack으로 이동시키는 구조다.
새로운 편집 작업이 발생한 경우에는 기존 Redo 기록을 제거했다.
이 방식으로 편집 과정에서 발생하는 상태 변경을 비교적 단순하게 관리할 수 있었다.
블록 그룹 기능
관리자 시스템에서 실제 레벨 제작에 가장 유용했던 기능 중 하나는 여러 블록을 하나의 그룹으로 저장하는 기능이었다.
예를 들어 벽이나 특정 장식 구조물을 여러 개의 블록으로 만들었다고 하면, 같은 구조물을 다시 만들기 위해 매번 블록을 하나씩 배치하는 것은 비효율적이다.
그래서 3D 공간에서 시작점과 끝점을 지정하면 해당 영역의 블록들을 하나의 그룹으로 묶도록 했다.
AABB 영역을 기준으로 범위 안에 있는 블록을 찾아내고, 각 블록의 절대 좌표 대신 그룹 중심점을 기준으로 한 상대 좌표를 저장했다.
AABB 영역 선택 -> 영역 내부 블록 검색 -> 상대 좌표 계산 -> BlockGroupModel 생성 -> 저장
이렇게 만든 그룹은 하나의 템플릿처럼 사용할 수 있다.
관리자 UI에 생성된 그룹이 등록되고, 이후 해당 그룹을 3D 맵에 배치하면 저장된 상대 좌표를 기준으로 여러 블록이 한 번에 생성된다.
결과적으로 자주 사용하는 구조물을 일종의 프리셋처럼 재사용할 수 있게 됐다.
런타임 3D 썸네일 생성
블록 그룹 기능을 만들면서 새로운 문제가 생겼다.
관리자 UI에서 블록 그룹을 보여주려면 이미지가 필요했는데, 사용자가 런타임에서 직접 만든 그룹이기 때문에 미리 만들어 놓은 이미지가 존재하지 않았다.
기본 블록 역시 새로운 블록이 추가될 때마다 별도의 UI 아이콘을 제작해야 한다면 관리자 시스템을 확장할수록 리소스 관리 비용이 증가하게 된다.
그래서 3D 모델 자체를 런타임에서 렌더링해서 UI 이미지로 사용하는 방식을 선택했다.
별도의 캡처 카메라와 임시 오브젝트 영역을 만들어 메인 게임 화면과 분리된 캡처 환경을 구성했다.
Block / BlockGroup
-> 임시 3D 오브젝트 생성
-> Capture Camera 렌더링
-> RenderTexture
-> Texture2D
-> 배경 제거
-> Sprite 생성
-> 관리자 UI 표시
이렇게 하면 새로운 블록이나 사용자가 만든 블록 그룹에 대해서도 별도의 2D 리소스를 제작하지 않고 바로 UI에서 사용할 수 있다.
캡처 전용 스튜디오
캡처 과정에서 가장 먼저 처리한 것은 메인 씬과 캡처 영역을 분리하는 것이었다.
캡처용 카메라를 별도로 두고 임시 BlockRoot 아래에 캡처 대상 오브젝트를 생성했다.
카메라는 Orthographic으로 설정했다.
관리자 UI에서 사용하는 썸네일은 실제 플레이 화면처럼 원근감이 강하게 들어갈 필요가 없었고, 블록의 형태를 일정한 기준으로 보여주는 것이 더 중요했기 때문이다.
복합 블록 그룹은 크기가 일정하지 않기 때문에 카메라 크기도 고정할 수 없었다.
그래서 그룹의 크기를 기준으로 orthographicSize를 동적으로 계산했다.
_blockCaptureCamera.orthographicSize =
Vector3.Distance(Vector3.zero, groupData.BlockSize);
그리고 그룹에 포함된 블록들은 그룹 중심점을 기준으로 다시 배치했다.
blockObj.transform.localPosition =
block.Position - groupData.BlockCenterPos;
이렇게 하면 어떤 위치에서 생성된 그룹이든 캡처 영역에서는 항상 일정한 기준으로 정렬된다.
RenderTexture 기반 오프스크린 캡처
실제 이미지 생성은 RenderTexture를 사용했다.
캡처 카메라에 256×256 크기의 RenderTexture를 연결하고 Camera.Render()를 직접 호출했다.
Camera
-> RenderTexture 256x256
-> Texture2D.ReadPixels()
-> Sprite
렌더링 결과를 Texture2D로 읽어온 뒤 Sprite로 변환했다.
이 방식의 장점은 별도의 이미지 리소스를 미리 만들어 놓지 않아도 된다는 점이다.
특히 사용자가 생성한 블록 그룹처럼 에디터 실행 시점에는 존재하지 않는 콘텐츠도 동일한 파이프라인을 사용할 수 있었다.
배경 제거
캡처 결과를 UI에서 사용하려면 배경이 없어야 했다.
이를 위해 캡처 카메라의 Clear Color를 마젠타 계열로 지정하고, 캡처된 텍스처에서 해당 색상을 찾아 알파값을 0으로 변경하는 방식을 사용했다.
if (pixel.r == colorToMakeTransparent.r &&
pixel.g == colorToMakeTransparent.g &&
pixel.b == colorToMakeTransparent.b)
{
pixel.a = 0f;
}
이 과정을 거쳐 최종적으로 배경이 투명한 Sprite를 만들었다.
완전히 별도의 이미지 편집 과정 없이 3D 모델 -> UI Sprite 변환이 런타임에서 끝나는 구조다.
캡처 성능 문제
처음에는 모든 블록의 썸네일을 한 번에 생성하면 된다고 생각했다.
하지만 실제로 수십 개의 블록을 시작 시점에 한꺼번에 캡처하자 문제가 발생했다.
Texture2D.ReadPixels()는 GPU에서 렌더링된 결과를 CPU로 가져오는 과정이기 때문에 GPU와 CPU 사이의 동기화가 발생한다.
블록이 많아질수록 이 작업이 한 프레임에 몰리면서 시작 시점에 눈에 띄는 프레임 끊김이 발생했다.
그래서 캡처 작업을 한 번에 처리하지 않고 프레임 단위로 나눴다.
Frame 1 -> Block A Capture
Frame 2 -> Block B Capture
Frame 3 -> Block C Capture
...
코루틴에서 WaitForEndOfFrame()을 사용해 한 프레임에 하나씩 처리하도록 구성했다.
이렇게 해서 관리자 시스템이 시작될 때 발생하는 긴 프리징을 줄일 수 있었다.
생성된 Sprite는 다시 렌더링하지 않도록 Dictionary<string, Sprite>에 캐싱했다.
BlockCode -> Sprite
이후 UI에서 같은 블록의 이미지를 필요로 할 때는 이미 만들어진 Sprite를 바로 가져오도록 했다.
관리자 UI 구조
관리자 시스템에서는 UI가 여러 기능과 직접 연결되기 시작하면 코드가 빠르게 복잡해질 수 있었다.
그래서 UI를 View와 Controller, Data Model로 분리하고 MVP 형태로 구성했다.
Model -> 데이터
View -> 화면
Controller -> 입력 및 상태 제어
Model에는 블록과 맵, 블록 그룹처럼 실제 데이터를 관리하는 객체를 두고, View에서는 UI 컴포넌트와 화면 표현만 담당하도록 했다.
Controller는 사용자 입력이나 데이터 변경을 받아 Model과 View 사이를 연결하는 역할을 맡았다.
공통 UI 컨트롤러
관리자 시스템에는 팝업과 패널이 여러 개 필요했다.
각 UI마다 열기, 닫기, 애니메이션, 활성화 처리를 별도로 구현하면 비슷한 코드가 계속 생긴다.
그래서 제네릭 기반의 MonoViewCtrlBase<T, V>를 만들어 공통 동작을 묶었다.
public class MonoViewCtrlBase<T, V> : MonoSingleton<T>
where T : MonoBehaviour
where V : MonoViewBase
{
[SerializeField] private V _view;
protected V View => _view;
public virtual void Open()
{
_view.gameObject.SetActive(true);
_view.Animation.Play(_popupViewResource.OpenAni.name);
}
public virtual void Close()
{
_view.Animation.Play(_popupViewResource.CloseAni.name);
CoroutineMonoCtrl.Instance.SetTimeout(() =>
{
_view.gameObject.SetActive(false);
}, _popupViewResource.OpenAni.length);
}
}
이 구조를 사용하면서 팝업마다 열기와 닫기 로직을 반복해서 작성할 필요가 줄었다.
새로운 관리자 UI를 추가할 때도 기본적인 View를 만들고 Controller를 연결하는 방식으로 확장할 수 있었다.
블록 팔레트 관리
블록 종류가 늘어나면서 또 다른 문제가 생겼다.
모든 블록을 한 화면에 생성해 놓으면 사용하지 않는 UI까지 계속 활성화되어 있어 관리하기도 어렵고 불필요한 UI 갱신도 발생한다.
그래서 블록을 blockGroupCode 기준으로 카테고리화하고 Dropdown을 통해 필요한 그룹만 선택하도록 했다.
전체 BlockData
-> blockGroupCode 기준 그룹화
-> Category Dropdown 생성
-> 선택된 그룹만 UI 활성화
사용자가 카테고리를 변경하면 이전 그룹의 UI는 비활성화하고 현재 그룹의 UI만 활성화한다.
여기서 중요한 점은 모든 블록을 화면에 계속 유지하는 것이 아니라 현재 작업에 필요한 데이터만 노출하는 것이었다.
동적 블록 그룹 UI
사용자가 3D 맵에서 새로운 블록 그룹을 만들면 관리자 UI에도 바로 나타나야 했다.
이를 위해 블록 그룹 데이터 딕셔너리에 변경 이벤트를 발생시키고 UI 관리자가 이를 구독하도록 했다.
BlockGroupDataDictionary
|
+-> Add Event
|
+-> Remove Event
|
v
BlockGroupElementManager
|
+-> UI 생성
+-> UI 삭제
새로운 그룹이 추가되면 UI Element를 생성하고 생성된 블록 그룹 Sprite를 연결한다.
반대로 그룹이 삭제되면 해당 UI Element도 함께 제거한다.
덕분에 3D 편집 영역과 관리자 UI가 서로 직접 참조하면서 상태를 맞출 필요가 없었다.
데이터가 변경되면 이를 구독하고 있는 UI가 자신의 상태를 갱신하는 방식으로 구성했다.
UI에서 3D 맵으로 드래그 앤 드롭
블록 그룹을 단순히 목록에서 선택하는 것보다 실제 맵에 바로 배치할 수 있도록 만드는 것이 관리자 입장에서 더 편리하다고 판단했다.
그래서 UI의 블록 그룹 아이콘을 마우스로 드래그해서 3D 공간에 놓으면 해당 위치에 그룹이 복제되도록 구현했다.
UI에서는 Unity EventSystem의 IBeginDragHandler, IDragHandler, IEndDragHandler를 사용했다.
드래그가 시작되면 UI 아이콘을 마우스 위치에 따라 이동시키고, 드래그가 끝나는 순간 화면 좌표를 3D 공간으로 변환했다.
UI Element
-> BeginDrag
-> Mouse Position 추적
-> EndDrag
-> ScreenPointToRay
-> 3D 충돌 위치 계산
-> BlockGroup 복제
결국 관리자는 만들어둔 블록 그룹을 UI에서 선택한 뒤 원하는 위치에 끌어다 놓는 것만으로 복합 구조물을 배치할 수 있게 됐다.
맵 데이터 관리
관리자 시스템에서 편집한 결과는 실제 데이터로 저장되어야 했다.
맵 데이터에는 배치된 블록 정보와 블록 그룹 정보 등을 포함했고, 저장하기 전에 맵 규격에 맞는지 검증하도록 했다.
특히 프로젝트에서 사용하는 맵의 그리드 규칙에 맞춰 홀수 단위 규격을 검사했다.
저장 데이터는 로컬에 암호화하여 저장하고 필요할 때 다시 복호화하여 불러오는 방식으로 처리했다.
관리자 시스템의 목적상 단순히 데이터를 저장하는 것보다 잘못된 데이터가 저장되지 않도록 검증하는 과정도 중요했다.
전체 관리자 워크플로우
최종적으로 시스템은 다음과 같은 흐름으로 동작한다.
3D 레벨 편집
-> 블록 선택 및 배치
-> AABB 영역 선택
-> 블록 그룹 생성
-> 상대 좌표 기반 데이터 저장
-> 캡처 스튜디오에 임시 3D 모델 생성
-> Orthographic Camera 렌더링
-> RenderTexture
-> Texture2D.ReadPixels
-> 배경 제거
-> Sprite 생성
-> BlockGroupDataDictionary 변경 이벤트
-> 관리자 UI Element 생성
-> UI에서 블록 그룹 확인
-> 드래그 앤 드롭
-> ScreenPointToRay
-> 3D 맵 위치 계산
-> 블록 그룹 복제 배치
하나의 기능처럼 보이지만 실제로는 3D 편집, 데이터 관리, 렌더링, UI, 이벤트 시스템이 연결되어 있다.
이 구조를 만들면서 관리자 시스템은 단순한 옵션 메뉴가 아니라 게임 콘텐츠를 제작하고 다시 사용할 수 있는 하나의 제작 도구에 가까워졌다.
개발하면서 얻은 점
이번 시스템에서 가장 크게 얻은 것은 기능 하나하나의 구현보다 관리자 도구를 만들 때는 작업 흐름 전체를 봐야 한다는 점이었다.
처음에는 블록을 배치하는 기능만 만들면 된다고 생각했다.
하지만 실제 사용 흐름을 따라가 보면 블록을 배치한 다음에는 저장해야 하고, 반복해서 사용하는 구조물은 그룹화해야 하고, 그룹을 관리하려면 UI에서 식별할 이미지가 필요했다.
그런데 그 이미지는 런타임에서 생성한 데이터이기 때문에 미리 준비할 수 없었다.
결국 하나의 요구사항이 다른 시스템의 설계 문제로 이어졌다.
그래서 단순히 기능을 추가하는 방식보다는 다음과 같이 작업 흐름을 기준으로 시스템을 연결했다.
콘텐츠 제작
-> 데이터화
-> 시각화
-> 관리
-> 재사용
또한 런타임 캡처에서 발생한 ReadPixels 성능 문제처럼 처음에는 보이지 않았던 병목도 실제 데이터를 많이 넣어보면서 확인할 수 있었다.
특히 관리자 시스템은 일반적인 게임 플레이 화면보다 데이터의 양과 반복적인 작업량이 빠르게 증가할 수 있기 때문에, 처음부터 캐싱이나 프레임 분산 같은 부분을 고려할 필요가 있었다.
결과적으로 이번 개발에서는 단순히 3D 에디터를 구현하는 것에 그치지 않고, 콘텐츠를 생성하고 저장하고 시각화하고 다시 배치하는 전체 관리 사이클을 하나의 시스템으로 연결했다.
관리자 시스템의 가치는 기능의 개수보다 실제 작업자가 얼마나 적은 조작으로 콘텐츠를 만들고 관리할 수 있는지에 있다고 생각한다.
이번 프로젝트에서는 그 부분을 중심으로 구조를 설계하고 구현했다.