---
title: "클릭은 콜라이더가 받고 UI 는 월드에 놓여 있다"
description: "NGUI 의 버튼에는 콜라이더가 붙어 있고, UICamera 가 물리 레이캐스트로 그걸 맞힙니다. 가까운 것이 아니라 앞에 있는 것이 이기는 이유, SendMessage 로 오는 알림, UIRoot 가 루트를 2/화면높이로 축소해 두는 좌표계까지 소스에서 확인했습니다."
author: "유니얼"
pubDate: 2026-09-20
category: "game-dev/unity/ui"
tags: ["Unity", "UI", "NGUI", "라이브러리"]
canonical: https://blog.unialgames.com/posts/ngui-input-world/
---
[uGUI 는 NGUI 에서 시작했다](/posts/ngui-history/)에서 갈리는 지점 셋을 짚었고, [그리는 순서는 하이어라키가 아니라 숫자가 정한다](/posts/ngui-depth-order/)에서 첫째를 봤습니다. 남은 둘이 이번입니다.

둘을 따로 쓸 생각이었는데 소스를 열어 보니 한 이야기였습니다. **UI 가 월드에 놓인 오브젝트라서 입력도 물리로 받습니다.** 순서가 반대가 아니라 이쪽입니다.

## 버튼에 콜라이더가 붙어 있습니다

NGUI 로 버튼을 만들면 `BoxCollider` 가 같이 붙습니다. `NGUITools.AddWidgetCollider` 가 하는 일이 그것입니다. 붙일 때 두 가지를 같이 설정합니다.

```csharp
box = go.AddComponent<BoxCollider>();
box.isTrigger = true;
var widget = go.FastGetComponent<UIWidget>();
if (widget != null) widget.autoResizeBoxCollider = true;
```

`isTrigger` 라서 물리 충돌은 안 일으킵니다. 레이캐스트에만 걸리라고 붙은 것입니다. `autoResizeBoxCollider` 가 켜져 있으니 위젯 크기를 바꾸면 콜라이더도 따라 바뀝니다.

씬에 `UICamera` 의 `eventType` 이 2D 계열로 되어 있으면 `BoxCollider2D` 를 대신 붙입니다. 어느 쪽이든 **버튼에 콜라이더가 있는 것이 정상** 입니다.

이어받은 프로젝트에서 "쓸데없는 콜라이더가 붙어 있네" 하고 지우면 그 버튼은 안 눌립니다. uGUI 의 Raycast Target 체크를 끄는 것과 같은 결과인데, 여기서는 지웠다는 자각조차 없이 일어납니다.

## UICamera 가 레이를 쏩니다

받는 쪽은 카메라입니다. `UICamera` 는 `[RequireComponent(typeof(Camera))]` 이고 컴포넌트 메뉴 이름이 **"NGUI/UI/Event System (UICamera)"** 입니다. 소스 주석이 이렇게 시작합니다.

> "This script should be attached to each camera that's used to draw the objects with UI components on them."

UI 를 그리는 카메라마다 하나씩 붙입니다. 그 카메라가 화면 좌표에서 레이를 만들어 쏩니다.

<figure class="diagram">
<svg viewBox="0 0 680 302" role="img" aria-label="화면을 누른 뒤 UICamera 가 레이를 쏘고 콜라이더를 맞힌 뒤 depth 로 정렬해 SendMessage 까지 가는 경로">
<text x="24" y="24" font-size="13" fill="var(--text)">클릭 한 번이 지나가는 길</text>
<rect x="24" y="48" width="376" height="38" rx="7" fill="var(--bg-subtle)" stroke="var(--border)"></rect>
<text x="40" y="72" font-size="11" fill="var(--text)">화면을 누릅니다</text>
<text x="416" y="72" font-size="10" fill="var(--text-muted)">마우스나 터치</text>
<line x1="60" y1="86" x2="60" y2="92" stroke="var(--text-muted)" stroke-width="1"></line>
<polygon points="60,96 55,90 65,90" fill="var(--text-muted)"></polygon>
<rect x="24" y="96" width="376" height="38" rx="7" fill="var(--bg-subtle)" stroke="var(--accent)"></rect>
<text x="40" y="120" font-size="11" fill="var(--text)">UICamera 가 그 점으로 레이를 만듭니다</text>
<text x="416" y="120" font-size="10" fill="var(--text-muted)">카메라마다 하나씩 붙습니다</text>
<line x1="60" y1="134" x2="60" y2="140" stroke="var(--text-muted)" stroke-width="1"></line>
<polygon points="60,144 55,138 65,138" fill="var(--text-muted)"></polygon>
<rect x="24" y="144" width="376" height="38" rx="7" fill="var(--bg-subtle)" stroke="var(--border)"></rect>
<text x="40" y="168" font-size="11" fill="var(--text)">레이어로 거르고 콜라이더를 맞힙니다</text>
<text x="416" y="168" font-size="10" fill="var(--text-muted)">cullingMask &amp; eventReceiverMask</text>
<line x1="60" y1="182" x2="60" y2="188" stroke="var(--text-muted)" stroke-width="1"></line>
<polygon points="60,192 55,186 65,186" fill="var(--text-muted)"></polygon>
<rect x="24" y="192" width="376" height="38" rx="7" fill="var(--bg-subtle)" stroke="var(--accent)"></rect>
<text x="40" y="216" font-size="11" fill="var(--text)">맞은 것들을 raycastDepth 로 정렬합니다</text>
<text x="416" y="216" font-size="10" fill="var(--text-muted)">거리가 아닙니다</text>
<line x1="60" y1="230" x2="60" y2="236" stroke="var(--text-muted)" stroke-width="1"></line>
<polygon points="60,240 55,234 65,234" fill="var(--text-muted)"></polygon>
<rect x="24" y="240" width="376" height="38" rx="7" fill="var(--bg-subtle)" stroke="var(--border)"></rect>
<text x="40" y="264" font-size="11" fill="var(--text)">맨 앞 오브젝트에 SendMessage</text>
<text x="416" y="264" font-size="10" fill="var(--text-muted)">OnClick() 이 있으면 불립니다</text>
<text x="24" y="294" font-size="11" fill="var(--text-muted)">받는 쪽이 등록하는 게 아니라, 보내는 쪽이 이름으로 부릅니다</text>
</svg>
<figcaption>물리 레이캐스트로 시작해서 함수 이름으로 끝납니다.</figcaption>
</figure>

쏘는 방식이 네 가지입니다. 주석이 각각 한 줄로 적혀 있습니다.

| `eventType` | 소스 주석 |
| --- | --- |
| `World_3D` | "Perform a Physics.Raycast and sort by distance to the point that was hit" |
| `UI_3D` | "Perform a Physics.Raycast and sort by widget depth" |
| `World_2D` | "Perform a Physics2D.OverlapPoint" |
| `UI_2D` | "Physics2D.OverlapPoint then sort by widget depth" |

기본값이 `UI_3D` 입니다. 무엇을 맞힐지는 레이어로 거릅니다. 코드에서 `currentCamera.cullingMask & (int)cam.eventReceiverMask` 로 둘을 겹쳐 쓰니, **카메라가 안 그리는 레이어는 클릭도 안 받습니다.**

이 구조 때문에 따라오는 것이 있습니다. UI 를 여러 카메라로 나눠 그리면 `UICamera` 도 카메라 수만큼 붙습니다. 3D 월드를 누르는 것과 UI 를 누르는 것이 서로 다른 카메라의 일이 되고, 레이어로 갈라 둡니다.

## 가까운 것이 아니라 앞에 있는 것이 이깁니다

`UI_3D` 가 하는 일을 한 번 더 봅니다. 레이를 쏘는 것까지는 물리인데, **고르는 기준이 거리가 아닙니다.**

<x-raycast>
<script type="application/json">
{"label":"겹쳐 있을 때 누가 클릭을 받는가","note":"셋 다 레이가 지나가는 자리에 있습니다. 고르는 기준만 다릅니다.","default":{"mode":0,"col":0}}
</script>
</x-raycast>

맞은 것들을 전부 모은 다음 `NGUITools.CalculateRaycastDepth` 로 각각의 깊이를 구하고, 큰 것부터 내림차순으로 정렬합니다. 그 깊이가 [앞 편](/posts/ngui-depth-order/)에서 본 `raycastDepth` 입니다.

```csharp
return (panel != null) ? mDepth + panel.depth * 1000 : mDepth;
```

**그리는 순서와 클릭 받는 순서가 같은 숫자에서 나옵니다.** 앞에 그려진 것이 클릭도 먼저 받습니다. 거리를 안 보니까 UI 를 카메라 앞 어디에 놓든 상관없습니다.

`CalculateRaycastDepth` 에는 한 가지가 더 들어 있습니다. 맞은 오브젝트에 위젯이 없으면 자식들의 위젯을 모아서 **그중 제일 작은 깊이** 를 씁니다. 콜라이더를 부모에 하나만 붙이고 속에 위젯 여러 개를 넣는 구조가 이래서 동작합니다.

uGUI 와 UI Toolkit 을 나란히 놓으면 이렇습니다.

| 체계 | 클릭을 받는 조건 | 겹쳤을 때 이기는 것 |
| --- | --- | --- |
| uGUI | Graphic 의 Raycast Target | 하이어라키상 앞에 있는 것 |
| NGUI | **콜라이더** | `raycastDepth` 가 큰 것 |
| UI Toolkit | `pickingMode` | 앞에 그려진 것 (그리는 순서의 반대로 검사) |

셋 다 "앞에 있는 것이 이긴다" 는 같은데, **앞을 정하는 방법이 각각 다릅니다.** 그게 [2편](/posts/ngui-depth-order/)에서 본 순서 이야기와 같은 갈림길입니다.

## 알림은 함수 이름으로 옵니다

맞힌 다음에는 알려 줘야 합니다. `UICamera.Notify` 가 그 일을 합니다.

```csharp
go.SendMessage(funcName, obj, SendMessageOptions.DontRequireReceiver);
```

**`SendMessage` 입니다.** 이름이 맞는 함수가 그 오브젝트에 있으면 불리고, 없으면 조용히 넘어갑니다. 그래서 NGUI 프로젝트의 스크립트에는 이런 함수들이 붙어 있습니다.

```csharp
void OnClick () { }
void OnPress (bool isPressed) { }
void OnDrag (Vector2 delta) { }
void OnHover (bool isOver) { }
```

인터페이스를 구현하는 것도 아니고 등록하는 것도 아닙니다. **이름만 맞으면 됩니다.** 오타가 나면 컴파일은 통과하고 그냥 안 불립니다. 이어받은 프로젝트에서 "이 함수를 누가 부르지" 하고 찾아봐도 호출부가 안 나오는 이유가 이것입니다.

`UICamera` 가 보내는 이름은 열댓 개입니다. `OnHover` `OnPress` `OnSelect` `OnClick` `OnDoubleClick` `OnDragStart` `OnDrag` `OnDragOver` `OnDragOut` `OnDragEnd` `OnTooltip` `OnScroll` `OnNavigate` `OnPan` `OnKey`.

인스펙터로 거는 방법도 따로 있습니다. `UIButton` 에는 `List<EventDelegate> onClick` 이 있어서 uGUI 의 On Click 칸처럼 끌어다 붙입니다. [이벤트는 루트에서 내려갔다가 다시 올라온다](/posts/uitk-events/)에서 본 UI Toolkit 과 비교하면, **인스펙터 연결이 없는 쪽은 셋 중 UI Toolkit 하나뿐** 입니다.

## UI 는 월드에 놓여 있습니다

여기까지 오면 왜 물리로 받는지가 보입니다. NGUI 의 UI 는 화면에 얹는 무엇이 아니라 **씬 안의 진짜 오브젝트** 입니다. 메시가 있고 콜라이더가 있고 좌표가 있습니다.

그 좌표를 맞춰 주는 것이 `UIRoot` 입니다. 주석이 한 줄입니다.

> "This is a script used to keep the game object scaled to 2/(Screen.height)."

<figure class="diagram">
<svg viewBox="0 0 680 262" role="img" aria-label="UI 좌표 720칸이 루트 스케일 2 나누기 720 을 거쳐 월드 2유닛이 되는 그림">
<text x="24" y="24" font-size="13" fill="var(--text)">UI 좌표가 월드 좌표가 되는 방법</text>
<text x="40" y="46" font-size="11.5" fill="var(--accent2)">UI 좌표</text>
<rect x="40" y="54" width="190" height="150" rx="9" fill="var(--bg-subtle)" stroke="var(--accent2)"></rect>
<rect x="64" y="104" width="142" height="40" rx="6" fill="var(--bg)" stroke="var(--border)"></rect>
<text x="135" y="129" font-size="10.5" fill="var(--text)" text-anchor="middle">버튼</text>
<line x1="30" y1="54" x2="30" y2="204" stroke="var(--accent2)" stroke-width="1"></line>
<text transform="rotate(-90 20 129)" x="20" y="129" font-size="10" fill="var(--accent2)" text-anchor="middle">높이 720 칸</text>
<line x1="244" y1="129" x2="326" y2="129" stroke="var(--text-muted)" stroke-width="1.2"></line>
<polygon points="334,129 322,124 322,134" fill="var(--text-muted)"></polygon>
<text x="288" y="112" font-size="10" fill="var(--text)" font-family="var(--font-mono)" text-anchor="middle">localScale</text>
<text x="288" y="152" font-size="10" fill="var(--accent)" font-family="var(--font-mono)" text-anchor="middle">2 / 720</text>
<text x="350" y="46" font-size="11.5" fill="var(--accent)">월드 좌표</text>
<rect x="350" y="54" width="190" height="150" rx="9" fill="var(--bg-subtle)" stroke="var(--accent)"></rect>
<rect x="374" y="104" width="142" height="40" rx="6" fill="var(--bg)" stroke="var(--border)"></rect>
<text x="445" y="129" font-size="10.5" fill="var(--text)" text-anchor="middle">같은 버튼</text>
<line x1="552" y1="54" x2="552" y2="204" stroke="var(--accent)" stroke-width="1"></line>
<text transform="rotate(-90 566 129)" x="566" y="129" font-size="10" fill="var(--accent)" text-anchor="middle">높이 2 유닛</text>
<text x="588" y="112" font-size="10" fill="var(--text-muted)">정사영 카메라</text>
<text x="588" y="128" font-size="10" fill="var(--text-muted)" font-family="var(--font-mono)">size = 1</text>
<text x="588" y="146" font-size="10" fill="var(--text-muted)">이 딱 덮습니다</text>
<text x="24" y="232" font-size="11" fill="var(--text-muted)">루트가 미리 줄여 놔서 위젯의 localPosition 100 이 화면에서 100픽셀쯤 됩니다</text>
<text x="24" y="250" font-size="11" fill="var(--text-muted)">같은 공간이라 3D 오브젝트를 UI 사이에 그대로 끼울 수 있습니다</text>
</svg>
<figcaption>UIRoot 가 Update 에서 매 프레임 이 스케일을 맞춥니다.</figcaption>
</figure>

실제로 하는 일도 그 한 줄입니다.

```csharp
float size = 2f / calcActiveHeight;
mTrans.localScale = new Vector3(size, size, size);
```

`Update` 에서 매 프레임 돌면서 루트의 스케일을 이 값으로 맞춥니다. 화면 높이가 720 이면 스케일이 `2/720` 이고, 그러면 **UI 720칸이 월드 2유닛** 이 됩니다. 정사영 카메라의 `orthographicSize` 가 1 일 때 보이는 높이가 딱 2유닛이니, 화면에 정확히 맞습니다.

따라오는 것이 둘입니다.

- **좌표가 픽셀처럼 보입니다.** 위젯의 `localPosition` 을 100 으로 두면 화면에서 대략 100픽셀만큼 옮겨집니다. 루트가 그만큼 줄여 놨기 때문입니다
- **3D 오브젝트를 UI 사이에 끼울 수 있습니다.** 캐릭터 모델을 UI 카메라가 비추는 자리에 놓으면 UI 와 같은 공간에 섞입니다. uGUI 의 Screen Space Overlay 에서는 이게 안 되고, World Space 캔버스로 바꿔야 했습니다

## 해상도 대응은 UIRoot 의 Scaling 입니다

`UIRoot.Scaling` 이 셋입니다. `Flexible`, `Constrained`, `ConstrainedOnMobiles`.

`Flexible` 은 위에서 본 계산을 화면 높이에 그대로 씁니다. 1 UI 칸이 1 픽셀이 되는 셈이라 **화면이 커지면 UI 가 작아 보입니다.** 대신 `minimumHeight` 320 과 `maximumHeight` 1536 으로 위아래를 죕니다. 너무 작거나 너무 큰 화면에서는 고정 크기처럼 동작합니다.

`Constrained` 는 `manualWidth` 1280 과 `manualHeight` 720 을 기준으로 잡습니다. [Canvas Scaler 는 가로와 세로 중 한쪽만 지킨다](/posts/unity-ui-canvas-scaler-safe-area/)에서 본 것과 같은 자리이고, `fitWidth` 와 `fitHeight` 체크 둘로 어느 쪽을 지킬지 고릅니다. 둘 다 켜면 Fit, 둘 다 끄면 Fill 입니다.

`ConstrainedOnMobiles` 는 모바일에서만 `Constrained` 로 동작합니다. 에디터와 PC 에서는 `Flexible` 입니다.

Canvas Scaler 의 Match 슬라이더 같은 연속 값이 없습니다. 체크 두 개로 네 조합입니다. 대응을 찾는다면 `fitHeight` 만 켠 것이 Match 1 에, `fitWidth` 만 켠 것이 Match 0 에 가깝습니다.

## 정리

- NGUI 의 버튼에는 `BoxCollider` 가 `isTrigger` 로 붙어 있고, 위젯 크기를 따라갑니다. **지우면 안 눌립니다**
- `UICamera` 가 카메라마다 붙어서 레이를 쏩니다. `cullingMask & eventReceiverMask` 라 **카메라가 안 그리는 레이어는 클릭도 안 받습니다**
- 기본 `UI_3D` 는 **거리가 아니라 `raycastDepth` 로 고릅니다.** 그리는 순서와 클릭 순서가 같은 숫자에서 나옵니다
- 알림은 `SendMessage` 로 옵니다. `void OnClick ()` 처럼 **이름만 맞으면 불립니다.** 호출부를 찾아도 안 나옵니다
- 인스펙터 연결도 있습니다(`List<EventDelegate> onClick`). 셋 중 그게 없는 쪽은 UI Toolkit 뿐입니다
- `UIRoot` 가 루트를 `2/화면높이` 로 축소합니다. 그래서 좌표가 픽셀처럼 보이고, 3D 오브젝트를 UI 사이에 끼울 수 있습니다
- 해상도 대응은 `Flexible` / `Constrained` / `ConstrainedOnMobiles` 셋에 체크 두 개입니다. Match 같은 연속 값은 없습니다