에디터 툴을 만들다 보면 Prefab View와 같이 메인 SceneView와 별개로 특정 오브젝트만 보여주는 독립적인 씬 뷰가 있다면 좋겠다는 생각이 들 때가 있습니다. 하지만 기존 SceneView를 EditorWindow 안에 직접 임베드하기는 어렵습니다.
이 글에서는 UI Toolkit 기반 EditorWindow에서 IMGUIContainer와 PreviewRenderUtility를 조합해 독립적인 SceneView를 구현하고, 오브젝트 스폰과 카메라 조작 이벤트 등록하는 방법을 단계별로 정리해 보겠습니다.
1. 구조 설계 및 각 요소 역할
구현에 들어가기 전에 전체 구조를 먼저 잡아보겠습니다. 만들려고 하는 독립 SceneView는 아래와 같은 흐름으로 구성됩니다.

EditorWindow: UI Toolkit 기반 에디터 창의 진입점입니다. CreateGUI에서 UI 구성을 시작합니다.
VisualElement: UI Toolkit의 기본 레이아웃 단위로, 여기에 자식 요소들을 배치합니다.
IMGUIContainer: VisualElement 트리 안에서 IMGUI 코드를 실행할 수 있게 해주는 브릿지 역할을 합니다. UI Toolkit은 직접적인 씬 렌더링 요소를 제공하지 않기 때문에, IMGUI를 통해 렌더링 결과를 그려주는 이 컨테이너가 필요합니다.
PreviewRenderUtility: 메인 씬과 분리된 독립 렌더링 환경을 제공합니다. 이 안에서 카메라, 라이트, 오브젝트를 세팅하고 결과를 텍스처로 뽑아냅니다.
PreviewRenderUtility가 렌더링한 텍스처를, IMGUIContainer의 onGUIHandler에서 GUI.DrawTexture로 화면에 그려주는 구조입니다. 이제 이 흐름을 순서대로 구현해 보겠습니다.
2. 독립 SceneView 구현
이제 정리한 구조를 코드로 구현해 보겠습니다.
2.1 EditorWindow 베이스 구현
먼저 SceneView를 띄울 EditorWindow를 만듭니다. 기본 창 크기를 가로 800, 세로 600으로 설정합니다.
class ToolSceneViewWindow : EditorWindow
{
[MenuItem("ToolSceneView/Open ToolSceneViewWindow")]
public static void ShowWindow()
{
var window = GetWindow<ToolSceneViewWindow>("Tool Scene View");
window.minSize = new Vector2(800, 600);
}
private void CreateGUI()
{
}
}
이 상태에서 ToolSceneView → Open ToolSceneViewWindow 를 클릭하면 빈 EditorWindow가 열립니다.

2.2 CustomSceneView 클래스 생성 및 연결
VisualElement를 상속받는 CustomSceneView 클래스를 만들고, EditorWindow에 연결합니다. 아직 내부 구현은 비어 있지만, 먼저 뼈대를 잡아둡니다.
public class CustomSceneView : VisualElement
{
public CustomSceneView()
{
style.flexGrow = 1;
RegisterCallback<DetachFromPanelEvent>(OnDetachFromPanel);
}
private void OnDetachFromPanel(DetachFromPanelEvent evt)
{
}
}
EditorWindow의 CreateGUI에서 CustomSceneView 를 추가합니다.
private void CreateGUI()
{
var customSceneView = new CustomSceneView();
rootVisualElement.Add(customSceneView);
}
이렇게 분리하면 EditorWindow는 깔끔하게 유지되고, CustomSceneView는 다른 EditorWindow에서도 재사용할 수 있습니다.
2.3 PreviewRenderUtility 세팅
CustomSceneView 안에 PreviewRenderUtility를 추가합니다. 독립적인 렌더링 환경을 제공하는 핵심 요소입니다.
private PreviewRenderUtility m_previewRenderUtility;
public CustomSceneView()
{
style.flexGrow = 1;
InitPreviewRenderUtility();
RegisterCallback<DetachFromPanelEvent>(OnDetachFromPanel);
}
private void InitPreviewRenderUtility()
{
m_previewRenderUtility = new PreviewRenderUtility();
//Camera
var camera = m_previewRenderUtility.camera;
camera.fieldOfView = 30f;
camera.nearClipPlane = 0.01f;
camera.farClipPlane = 100f;
camera.transform.position = new Vector3(0f, 2f, -5f);
camera.transform.LookAt(Vector3.zero);
//Light
m_previewRenderUtility.lights[0].intensity = 1.4f;
m_previewRenderUtility.lights[0].transform.rotation = Quaternion.Euler(40f, 40f, 0f);
}
private void OnDetachFromPanel(DetachFromPanelEvent evt)
{
m_previewRenderUtility?.Cleanup();
m_previewRenderUtility = null;
}
InitPreviewRenderUtility에서 카메라의 시야각, 클리핑 범위, 위치를 설정하고 라이트를 세팅합니다.
DetachFromPanelEvent에서 Cleanup을 호출하여, 이 요소가 VisualElement 트리에서 제거될 때 리소스를 정리합니다. 이 부분을 빠뜨리면 메모리 누수가 발생할 수 있으므로 주의해야 합니다.
2.4 IMGUIContainer 연결
마지막으로 IMGUIContainer를 추가하여 PreviewRenderUtility의 렌더링 결과를 화면에 그립니다.
private IMGUIContainer m_imguiContainer;
public CustomSceneView()
{
style.flexGrow = 1;
InitPreviewRenderUtility();
InitIMGUIContainer();
RegisterCallback<DetachFromPanelEvent>(OnDetachFromPanel);
}
private void InitIMGUIContainer()
{
m_imguiContainer = new IMGUIContainer(OnRenderGUI);
m_imguiContainer.style.flexGrow = 1;
Add(m_imguiContainer);
}
private void OnRenderGUI()
{
if (m_previewRenderUtility == null) return;
var rect = GUILayoutUtility.GetRect(0, 0, GUILayout.ExpandWidth(true), GUILayout.ExpandHeight(true));
if (rect.width <= 0 || rect.height <= 0) return;
m_previewRenderUtility.BeginPreview(rect, GUIStyle.none);
m_previewRenderUtility.camera.Render();
var texture = m_previewRenderUtility.EndPreview();
GUI.DrawTexture(rect, texture, ScaleMode.StretchToFill, false);
}
OnRenderGUI의 흐름을 정리하면 다음과 같습니다.
GUILayoutUtility.GetRect로 IMGUIContainer가 차지하는 영역을 가져옵니다. BeginPreview로 해당 영역 크기에 맞는 렌더 타겟을 준비하고, camera.Render로 프리뷰 씬을 렌더링합니다. EndPreview로 렌더링 결과를 텍스처로 받아온 뒤, GUI.DrawTexture로 화면에 그립니다.
지금상태에서 화면을 그리면 카메라가 바닥을 향하므로 아래와 같이 나옵니다.

카메라를 수평으로 향하게 값을 바꾸면 아래와 같이 스카이박스가 같이 화면에 나옵니다.

3. 오브젝트 스폰으로 그리드 구성 및 단일 큐브 스폰
지금까지 만든 CustomSceneView는 아직 빈 화면입니다. PreviewRenderUtility로 구성한 독립 씬은 기존 SceneView와 달리 기즈모나 핸들 같은 에디터 기능을 사용할 수 없습니다.
이번에는 먼저 GL 드로잉으로 기즈모 대용 그리드를 그려 위치 확인을 편하게 한 뒤, 단일 큐브를 스폰해 보겠습니다.
3.1 그리드 렌더링
위치 확인용 그리드를 GL 클래스로 직접 그려줍니다.
GL은 Unity에서 GPU에 직접 그리기 명령을 내리는 즉시 모드 렌더링(Immediate Mode Rendering) 방식입니다.
씬에 오브젝트로 존재하지 않고, Unity의 기즈모 시스템을 거치지도 않지만, 렌더 버퍼에 선을 직접 그려주기 때문에 기즈모와 유사한 역할을 할 수 있습니다. PreviewRenderUtility 환경에서는 기즈모를 사용할 수 없으므로, GL을 활용하여 그리드를 그려줍니다.
CustomSceneView에 아래 코드를 추가합니다.
private Material m_gridMaterial;
private void InitGridMaterial()
{
m_gridMaterial = new Material(Shader.Find("Hidden/Internal-Colored"));
m_gridMaterial.SetInt("_ZWrite", 0);
m_gridMaterial.SetInt("_Cull", (int)CullMode.Off);
}
private void DrawGrid(int gridSize = 50, float spacing = 1f)
{
if (m_gridMaterial == null) return;
var camera = m_previewRenderUtility.camera;
GL.PushMatrix();
GL.LoadProjectionMatrix(camera.projectionMatrix);
GL.modelview = camera.worldToCameraMatrix;
m_gridMaterial.SetPass(0);
GL.Begin(GL.LINES);
GL.Color(new Color(0.5f, 0.5f, 0.5f, 0.3f));
float half = gridSize * spacing;
for (int i = -gridSize; i <= gridSize; i++)
{
float pos = i * spacing;
// X축 방향 선
GL.Vertex3(-half, 0f, pos);
GL.Vertex3(half, 0f, pos);
// Z축 방향 선
GL.Vertex3(pos, 0f, -half);
GL.Vertex3(pos, 0f, half);
}
GL.End();
GL.PopMatrix();
}
생성자에서 InitGridMaterial을 호출하고, OnRenderGUI에서 camera.Render 직후에 DrawGrid를 호출합니다.
public CustomSceneView()
{
style.flexGrow = 1;
InitPreviewRenderUtility();
InitGridMaterial();
InitIMGUIContainer();
RegisterCallback<DetachFromPanelEvent>(OnDetachFromPanel);
}
private void OnRenderGUI()
{
if (m_previewRenderUtility == null) return;
var rect = GUILayoutUtility.GetRect(0, 0, GUILayout.ExpandWidth(true), GUILayout.ExpandHeight(true));
if (rect.width <= 0 || rect.height <= 0) return;
m_previewRenderUtility.BeginPreview(rect, GUIStyle.none);
m_previewRenderUtility.camera.Render();
DrawGrid();
var texture = m_previewRenderUtility.EndPreview();
GUI.DrawTexture(rect, texture, ScaleMode.StretchToFill, false);
}
Hidden/Internal-Colored 셰이더는 Unity에 내장된 단색 렌더링용 셰이더로, GL 라인 드로잉에 적합합니다.
GL.PushMatrix / GL.PopMatrix로 현재 매트릭스 상태를 저장·복원합니다. GL로 직접 도형을 그릴 때는 현재 설정된 매트릭스를 기준으로 좌표가 변환되는데, camera.Render 이후에는 GL의 매트릭스가 프리뷰 카메라의 시점과 일치한다는 보장이 없습니다.
따라서 GL.LoadProjectionMatrix로 카메라의 투영 매트릭스를, GL.modelview로 카메라의 뷰 매트릭스를 직접 설정해야 GL로 그리는 선이 카메라 시점에 맞게 올바르게 렌더링됩니다. 이 설정이 없으면 그리드가 엉뚱한 위치에 표시되거나 아예 보이지 않을 수 있습니다.
GL.Begin와 GL.End 사이에서 GL.Vertex3로 격자선의 시작점과 끝점을 지정합니다. X축과 Z축 방향으로 각각 선을 그려 바닥면 격자를 구성합니다.
DrawGrid는 camera.Render와 EndPreview 사이에 호출해야 합니다. 이 시점에 그려야 프리뷰 렌더 타겟에 함께 포함됩니다.
다음엔 리소스 정리 코드도 추가합니다.
private void OnDetachFromPanel(DetachFromPanelEvent evt)
{
if (m_gridMaterial != null)
Object.DestroyImmediate(m_gridMaterial);
m_previewRenderUtility?.Cleanup();
m_previewRenderUtility = null;
}
이렇게 그리드를 그려두면 아래와 같이 기즈모 없이도 CustomSceneView의 공간감과 오브젝트 위치를 한눈에 파악할 수 있습니다.

3.2 단일 큐브 스폰
이제 실제로 활용할 큐브를 스폰해 보겠습니다. 외부에서 호출할 수 있도록 public 메서드로 추가합니다.
private List<GameObject> m_spawnedObjects = new List<GameObject>();
public void SpawnCube(GameObject prefab)
{
var obj = Object.Instantiate(prefab, Vector3.zero, Quaternion.identity);
m_previewRenderUtility.AddSingleGO(obj);
m_spawnedObjects.Add(obj);
}
private void ClearSpawnedObjects()
{
foreach (var obj in m_spawnedObjects)
{
if (obj != null)
Object.DestroyImmediate(obj);
}
m_spawnedObjects.Clear();
}
SpawnCube는 전달받은 프리팹을 원점에 생성하고, AddSingleGO로 PreviewRenderUtility의 독립 씬에 등록합니다. 이 메서드를 통해 등록해야만 프리뷰 카메라에 렌더링됩니다.
ClearSpawnedObjects는 SpawnCube 내부에서 자동으로 호출하지 않으므로, 필요한 시점에 외부에서 직접 호출하여 오브젝트를 정리할 수 있습니다. DestroyImmediate를 사용하는 이유는 에디터 환경에서는 Destroy가 즉시 실행되지 않기 때문입니다.
EditorWindow에서 아래와 같이 호출합니다.
private void CreateGUI()
{
var customSceneView = new CustomSceneView();
rootVisualElement.Add(customSceneView);
var cube = GameObject.CreatePrimitive(PrimitiveType.Cube);
customSceneView.SpawnCube(cube);
Object.DestroyImmediate(cube);
}
컴파일 후 확인하면 아래와 같이 큐브를 확인할 수 있습니다.

오브젝트 스폰 기능을 추가했으므로, DetachFromPanelEvent에서 스폰된 오브젝트도 함께 정리해야 합니다.
private void OnDetachFromPanel(DetachFromPanelEvent evt)
{
ClearSpawnedObjects();
if (m_gridMaterial != null)
Object.DestroyImmediate(m_gridMaterial);
m_previewRenderUtility?.Cleanup();
m_previewRenderUtility = null;
}
이제 리소스 누수 걱정 없이 사용할 수 있습니다.
4. 카메라 조작 이벤트 등록
CustomSceneView에 오브젝트를 배치했지만, 카메라가 고정되어 있으면 다양한 각도에서 확인하기 어렵습니다.
이번에는 마우스 스크롤로 줌인 · 줌아웃, 마우스 오른쪽 클릭 드래그로 중심 기준 공전 회전을 구현합니다.
4.1 카메라 변수 추가
CustomSceneView에 카메라 조작에 필요한 상태 변수를 추가합니다.
private Vector3 m_cameraTarget = Vector3.zero;
private float m_cameraDistance = 5.385f;
private float m_cameraYaw = 0f;
private float m_cameraPitch = -21.8f;
private const float MinDistance = 1f;
private const float MaxDistance = 50f;
private const float RotationSpeed = 0.3f;
private const float ZoomSpeed = 0.5f;
m_cameraTarget은 카메라가 바라보는 중심점입니다. 카메라는 이 지점을 기준으로 공전합니다.
m_cameraDistance는 중심점으로부터 카메라까지의 거리이며, MinDistance로 줌인 최소 거리를 제한합니다.
m_cameraYaw / m_cameraPitch는 중심점 기준 좌우·상하 회전 각도입니다.
4.2 카메라 위치 업데이트
상태 변수를 기반으로 카메라의 위치와 방향을 갱신하는 메서드를 추가합니다.
private void UpdateCameraTransform()
{
var rotation = Quaternion.Euler(m_cameraPitch, m_cameraYaw, 0f);
var direction = rotation * Vector3.back;
var position = m_cameraTarget - direction * m_cameraDistance;
var camera = m_previewRenderUtility.camera;
camera.transform.position = position;
camera.transform.LookAt(m_cameraTarget);
}
Yaw와 Pitch 값으로 회전을 만들고, 중심점에서 해당 방향의 반대로 m_cameraDistance만큼 떨어진 위치에 카메라를 배치합니다. LookAt으로 항상 중심점을 바라보게 합니다. InitPreviewRenderUtility에서 기존 카메라 위치 설정 코드를 UpdateCameraTransform 호출로 교체합니다.
private void InitPreviewRenderUtility()
{
m_previewRenderUtility = new PreviewRenderUtility();
//Camera
var camera = m_previewRenderUtility.camera;
camera.fieldOfView = 30f;
camera.nearClipPlane = 0.01f;
camera.farClipPlane = 100f;
camera.clearFlags = CameraClearFlags.Skybox;
UpdateCameraTransform();
//Light
m_previewRenderUtility.lights[0].intensity = 1.4f;
m_previewRenderUtility.lights[0].transform.rotation = Quaternion.Euler(40f, 40f, 0f);
}
4.3 마우스 이벤트 등록
OnRenderGUI 안에서 Event.current를 사용하여 마우스 입력을 처리합니다. 커서가 프리뷰 영역 안에 있을 때만 조작이 동작하도록 구현합니다.
private void OnRenderGUI()
{
if (m_previewRenderUtility == null) return;
var rect = GUILayoutUtility.GetRect(0, 0, GUILayout.ExpandWidth(true), GUILayout.ExpandHeight(true));
if (rect.width <= 0 || rect.height <= 0) return;
HandleMouseInput(rect);
m_previewRenderUtility.BeginPreview(rect, GUIStyle.none);
m_previewRenderUtility.camera.Render();
DrawGrid();
var texture = m_previewRenderUtility.EndPreview();
GUI.DrawTexture(rect, texture, ScaleMode.StretchToFill, false);
}
private void HandleMouseInput(Rect rect)
{
var evt = Event.current;
if (!rect.Contains(evt.mousePosition)) return;
switch (evt.type)
{
// 마우스 오른쪽 클릭 드래그 → 공전 회전
case EventType.MouseDrag when evt.button == 1:
m_cameraYaw += evt.delta.x * RotationSpeed;
m_cameraPitch -= evt.delta.y * RotationSpeed;
m_cameraPitch = Mathf.Clamp(m_cameraPitch, -89f, 89f);
UpdateCameraTransform();
evt.Use();
break;
// 마우스 스크롤 → 줌인·줌아웃
case EventType.ScrollWheel:
m_cameraDistance += evt.delta.y * ZoomSpeed;
m_cameraDistance = Mathf.Clamp(m_cameraDistance, MinDistance, MaxDistance);
UpdateCameraTransform();
evt.Use();
break;
}
}
rect.Contains으로 커서가 CustomSceneView의 프리뷰 영역 안에 있을 때만 입력을 처리합니다. EditorWindow에 버튼이나 슬라이더 같은 다른 UI 요소가 함께 배치되어 있을 경우, 이 체크가 없으면 프리뷰 영역 밖에서의 마우스 조작까지 카메라에 반영되어 의도치 않은 동작이 발생할 수 있습니다.
EventType.MouseDrag에서 마우스 오른쪽 버튼(button == 1) 드래그 시 delta 값을 Yaw와 Pitch에 적용합니다. Pitch는 -89도에서 89도로 제한하여 카메라가 뒤집히는 현상을 방지합니다.
EventType.ScrollWheel에서 스크롤 delta를 m_cameraDistance에 적용하고, MinDistance와 MaxDistance로 클램프하여 너무 가까이 다가가거나 너무 멀어지는 것을 방지합니다.
evt.Use를 호출하여 이벤트를 소비합니다. 이를 호출하지 않으면 다른 UI 요소에 이벤트가 전파되어 의도치 않은 동작이 발생할 수 있습니다.
이제 프리뷰 씬을 아래와 같이 마우스로 자유롭게 둘러볼 수 있습니다.

5. 주의사항
PreviewRenderUtility 기반의 독립 SceneView는 일반적인 EditorWindow 개발과 다른 점이 많아, 처음 구현할 때 예상치 못한 문제를 만나기 쉽습니다. 아래에 구현 과정에서 주의해야 할 점들을 정리합니다.
기존 SceneView 기능 사용 불가
PreviewRenderUtility로 구성한 독립 씬에서는 기존 SceneView에서 제공하는 Gizmos, Handles, OnSceneGUI 같은 에디터 기능을 사용할 수 없습니다. 이글에서 그리드를 GL로 직접 그린 것처럼, 필요한 시각 보조 요소나 이벤트 처리는 GL 드로잉이나 Event.current를 활용하여 직접 구현해야 합니다.
가비지 관련 주의
OnPreviewGUI는 IMGUIContainer에 의해 매 프레임 호출됩니다. 이 안에서 new 키워드로 객체를 생성하거나, 매번 리스트를 할당하는 등의 코드가 있으면 가비지가 누적되어 GC 스파이크를 유발할 수 있습니다. 상태 변수나 컬렉션은 필드로 선언하고, OnPreviewGUI 내부에서는 할당을 최소화해야 합니다.
GL.Lines 및 오브젝트 수에 따른 성능
GL.Lines로 그리는 격자선의 수와 AddSingleGO로 등록한 오브젝트 수가 많아질수록 렌더링 부하가 증가합니다. 그리드의 gridSize나 스폰 오브젝트 수는 실제 용도에 맞게 적절히 조절해야 합니다.
Repaint 빈도 관리
IMGUIContainer는 기본적으로 EditorWindow가 Repaint될 때마다 다시 그려집니다. 카메라 조작처럼 지속적인 갱신이 필요한 경우에는 문제가 없지만, 정적인 프리뷰를 보여줄 때도 불필요하게 자주 Repaint되면 성능 낭비가 발생할 수 있습니다. 필요에 따라 변경이 있을 때만 Repaint를 호출하도록 제어하는 것이 좋습니다.
리소스 정리
PreviewRenderUtility는 반드시 Cleanup을 호출하여 내부 리소스를 해제해야 합니다. 스폰된 오브젝트는 DestroyImmediate로, GL용 머티리얼도 마찬가지로 DestroyImmediate로 정리해야 합니다. 이를 빠뜨리면 EditorWindow를 열고 닫을 때마다 메모리 누수가 누적될 수 있습니다. 본문에서는 DetachFromPanelEvent에서 이를 처리했지만, 구조에 따라 OnDisable에서 정리하는 방식도 고려할 수 있습니다.
6. 정리
UI Toolkit 기반 EditorWindow 안에 PreviewRenderUtility와 IMGUIContainer를 조합하여 독립적인 SceneView를 구현하고, GL 그리드 렌더링, 오브젝트 스폰, 카메라 조작까지 다뤄보았습니다. 기존 SceneView에 비해 기즈모나 핸들을 직접 구현해야 하는 번거로움은 있지만, 메인 씬과 완전히 분리된 렌더링 환경을 자유롭게 구성할 수 있다는 점에서 PreviewRenderUtility는 충분히 실용적인 선택지입니다.
'Unity' 카테고리의 다른 글
| 6 Hidden C# Features Every Unity Developer Should Know 영상 내용 정리 (2) | 2025.07.08 |
|---|---|
| Unity Awaitable 을 Coroutine, UniTask 와 비교 (2) | 2025.01.04 |