17. Unity Raycast로 마우스 클릭 오브젝트 선택: ScreenPointToRay와 LayerMask
Unity에서 ScreenPointToRay로 레이를 만들고 LayerMask로 충돌 대상을 제한해 마우스 클릭한 오브젝트를 선택한다. 엔진 내부 호출 흐름과 성능 포인트까지 연결한다. 2D/3D 공통 팁 포함. (C# 예제 제공). (C# 예제 제공).
Unity Raycast로 마우스 클릭 오브젝트 선택: ScreenPointToRay와 LayerMask
게임 만들다 겪는 사고 중 흔한 게 "클릭했는데 엉뚱한 오브젝트가 선택됨"이다. UI 버튼을 눌렀는데 뒤에 있는 3D 오브젝트가 같이 선택되거나, 바닥 콜라이더가 먼저 맞아서 캐릭터가 선택되지 않는다. 레이캐스트는 겉으로는 한 줄이지만, 카메라 좌표 변환과 물리 월드 쿼리, 레이어 필터링까지 엔진 내부에서 단계가 많다. 이 흐름을 모르고 붙이면 버그가 재발한다. 클릭 선택을 엔진 관점으로 분해하면 디버깅 시간이 확 줄어든다. 처음에 나도 UI 위에서 클릭했는데도 오브젝트가 선택되는 현상을 3시간 잡았다. 콘솔에는 선택 로그가 찍히고, UI는 정상 클릭되는 것처럼 보이는데, 씬에서는 선택 하이라이트가 켜졌다. 원인은 EventSystem 체크 누락과 레이어 마스크 미설정이 동시에 섞인 케이스였다. 재현이 애
핵심 개념
클릭 선택은 "화면 좌표 → 월드 레이 → 물리 월드 쿼리"로 이어진다. 마우스 좌표는 픽셀 단위(Screen Space)이고, Physics는 월드 좌표(World Space)에서 동작한다. ScreenPointToRay는 이 간극을 메우는 변환기다. 이 변환이 없으면 클릭 위치와 실제 충돌 지점이 일치할 수 없다.
ScreenPointToRay가 반환하는 Ray는 원점(origin)과 방향(direction)만 가진 값 타입이다. 여기서 중요한 점은 "카메라가 어떤 투영을 쓰는지"다. Perspective 카메라는 화면의 한 점에서 카메라 원점으로부터 퍼지는 원뿔 형태의 레이를 만들고, Orthographic 카메라는 방향이 평행한 레이를 만든다. 같은 mousePosition이라도 카메라 설정이 다르면 월드에서 맞는 오브젝트가 달라진다.
LayerMask는 물리 쿼리 단계에서 후보 콜라이더를 미리 거르는 필터다. 클릭 선택이 불안정한 프로젝트는 대개 "바닥/트리거/투명 콜라이더" 같은 불필요한 콜라이더가 레이에 같이 걸린다. 레이어를 분리하고 마스크로 제한하면, 물리 엔진이 검사해야 하는 콜라이더 수 자체가 줄어들어 CPU 비용도 같이 내려간다.
RaycastHit는 충돌 결과를 담는 구조체다. hit.collider, hit.point, hit.normal, hit.distance가 들어있다. 초보가 자주 놓치는 건 "콜라이더를 맞춘 것"과 "게임플레이 오브젝트를 선택한 것"이 다르다는 점이다. 콜라이더는 자식에 붙어 있을 수 있고, 선택 대상은 루트에 있을 수 있다. 그래서 hit.collider.GetComponent 같은 즉흥 호출이 늘어나면 성능과 구조가 같이 무너진다.
1using UnityEngine;
2
3public class ClickPickBasic : MonoBehaviour
4{
5 [SerializeField] private Camera targetCamera;
6 [SerializeField] private LayerMask pickMask = ~0;
7
8 private void Reset()
9 {
10 targetCamera = Camera.main;
11 }
12
13 private void Update()
14 {
15 if (!Input.GetMouseButtonDown(0)) return;
16 if (targetCamera == null) return;
17
18 Ray ray = targetCamera.ScreenPointToRay(Input.mousePosition);
19 if (Physics.Raycast(ray, out RaycastHit hit, 2000f, pickMask, QueryTriggerInteraction.Ignore))
20 {
21 Debug.Log("Picked: " + hit.collider.name + " point=" + hit.point);
22 }
23 else
24 {
25 Debug.Log("Picked: none");
26 }
27 }
28}이 코드를 실행하면 Game 뷰에서 클릭할 때 콘솔에 "Picked: Cube" 같은 로그가 찍힌다. QueryTriggerInteraction.Ignore를 넣은 이유는 트리거 콜라이더가 클릭을 가로채는 상황이 실제로 자주 나오기 때문이다. 트리거까지 선택해야 하는 게임이라면 Ignore 대신 Collide를 쓴다. 마스크를 ~0으로 두면 모든 레이어가 대상이어서, 바닥이 먼저 맞는 문제를 바로 재현할 수 있다.
엔진 관점에서의 내부 동작
Update에서 Input.GetMouseButtonDown을 읽는 순간, C# 스크립트는 PlayerLoop의 Update 단계에서 실행 중이다. Unity는 프레임마다 네이티브 쪽에서 입력 이벤트를 수집하고(플랫폼별 윈도우 메시지/터치 이벤트), 그 결과를 관리하는 내부 상태를 C# API가 읽을 수 있게 노출한다. 그래서 GetMouseButtonDown은 "이번 프레임에 눌림으로 전이"된 상태를 반환한다.
Camera.ScreenPointToRay는 C#에서 보이지만 실제 계산은 네이티브 카메라 데이터(프로젝션/뷰 행렬, 클리핑 플레인, 렌더 타겟 크기)를 기반으로 한다. C# 래퍼는 카메라의 내부 핸들(네이티브 오브젝트 포인터를 간접 참조하는 ID)을 통해 C++ 쪽 카메라 상태를 읽고, 화면 좌표를 NDC(-1~1)로 정규화한 뒤 역행렬을 써서 월드 방향 벡터를 만든다.
Physics.Raycast는 C#에서 호출하지만 실제 충돌 검사는 PhysX(또는 Unity 물리 백엔드)의 네이티브 월드에서 진행된다. C# 호출은 내부 호출(InternalCall)로 네이티브 함수에 넘어가고, 네이티브는 현재 물리 씬(PhysicsScene)의 브로드페이즈 구조(AABB 트리/그리드 등)에서 레이가 지나갈 수 있는 후보 콜라이더를 먼저 추린 뒤, 좁은 단계에서 정확한 교차 테스트를 수행한다.
LayerMask는 이 브로드페이즈 후보 추리기에서 바로 적용된다. 즉, 마스크를 제대로 걸면 "아예 후보 목록에 안 들어간다". 반대로 마스크를 안 걸고 RaycastHit 결과에서 태그 비교로 거르는 방식은 이미 네이티브에서 교차 테스트 비용을 지불한 뒤라서, 선택 버그는 줄일 수 있어도 CPU 비용은 줄지 않는다.
메모리 관점에서 Ray, RaycastHit는 구조체라서 C# 힙 할당이 없다. 하지만 주의점이 있다. Debug.Log("Picked: " + hit.collider.name)은 문자열 결합으로 새 문자열을 만들고, 에디터에서는 로그가 EditorConsole에 쌓이면서 GC Alloc이 눈에 띄게 생긴다. 클릭 테스트 중에 GC가 튀는 이유가 레이캐스트가 아니라 로그인 경우가 많다.
PlayerLoop에서 물리 시뮬레이션은 FixedUpdate 타이밍에 진행되고, Raycast는 "쿼리"라서 Update에서도 호출 가능하다. 다만 레이캐스트는 그 순간의 물리 월드 스냅샷을 읽는다. Update에서 Transform을 움직인 직후 Raycast를 하면, 콜라이더 동기화가 아직 안 된 상태일 수 있다는 의심이 든다. Unity는 Transform 변경을 모아서 네이티브 물리에 반영하는 단계가 따로 있고(Physics.SyncTransforms 또는 내부 동기화), 이 타이밍 차이로 클릭이 한 프레임 늦게 맞는 듯한 현상이 나온다.
1using UnityEngine;
2
3public class PlayerLoopProbe : MonoBehaviour
4{
5 private int frame;
6
7 private void Awake() => Debug.Log("Awake f=" + Time.frameCount);
8 private void OnEnable() => Debug.Log("OnEnable f=" + Time.frameCount);
9 private void Start() => Debug.Log("Start f=" + Time.frameCount);
10
11 private void FixedUpdate()
12 {
13 if (Time.frameCount != frame)
14 {
15 frame = Time.frameCount;
16 Debug.Log("FixedUpdate f=" + Time.frameCount + " fixedTime=" + Time.fixedTime);
17 }
18 }
19
20 private void Update() => Debug.Log("Update f=" + Time.frameCount + " t=" + Time.time);
21 private void LateUpdate() => Debug.Log("LateUpdate f=" + Time.frameCount);
22}이 스크립트를 빈 오브젝트에 붙이고 Play하면 콘솔이 폭발한다. Update/LateUpdate는 프레임마다 1회, FixedUpdate는 프레임과 무관하게 고정 간격으로 여러 번 찍힐 수 있다. 클릭 선택을 Update에 두는 이유가 여기서 드러난다. 클릭은 입력 이벤트의 프레임 경계에 맞춰 처리해야 하고, FixedUpdate에 두면 눌림 전이(GetMouseButtonDown)가 누락되는 케이스가 나온다.
1using UnityEngine;
2
3public class RaycastSyncPitfall : MonoBehaviour
4{
5 [SerializeField] private Transform mover;
6 [SerializeField] private Camera targetCamera;
7 [SerializeField] private LayerMask mask = ~0;
8
9 private void Reset()
10 {
11 targetCamera = Camera.main;
12 }
13
14 private void Update()
15 {
16 if (mover != null)
17 {
18 mover.position = new Vector3(Mathf.Sin(Time.time) * 2f, mover.position.y, mover.position.z);
19 }
20
21 if (!Input.GetMouseButtonDown(0) || targetCamera == null) return;
22
23 Ray ray = targetCamera.ScreenPointToRay(Input.mousePosition);
24 bool hitA = Physics.Raycast(ray, out RaycastHit hit, 1000f, mask);
25
26 Physics.SyncTransforms();
27 bool hitB = Physics.Raycast(ray, out RaycastHit hit2, 1000f, mask);
28
29 Debug.Log("BeforeSync=" + (hitA ? hit.collider.name : "none") + " AfterSync=" + (hitB ? hit2.collider.name : "none"));
30 }
31}움직이는 오브젝트를 클릭할 때 BeforeSync와 AfterSync 결과가 달라질 때가 있다. 이 차이가 보이면, Transform 변경이 물리 콜라이더에 반영되는 타이밍 문제를 실제로 목격한 것이다. 매 프레임 SyncTransforms를 호출하는 건 비싸다. 선택이 필요한 프레임에만 제한적으로 쓰거나, Rigidbody 기반 이동으로 물리 동기화를 자연스럽게 맞추는 쪽이 낫다.
실습하기
1단계: 프로젝트 만들기와 레이어 준비
Unity Hub → New project → 3D (Built-in Render Pipeline) 템플릿을 선택한다. URP도 동일하게 동작하지만, 초보가 카메라/라이트 차이로 헷갈리는 포인트가 줄어든다. 프로젝트가 열리면 Edit → Project Settings → Physics에서 Layer Collision Matrix를 확인한다. 클릭 선택은 충돌 매트릭스와 직접 연결되지는 않지만, 레이어를 나눠두면 이후에 쿼리/충돌 정책을 같이 정리하기 쉽다.
Inspector 상단의 Layer 드롭다운 → Add Layer...로 이동해서 레이어 2개를 만든다. 예: Pickable, Ground. 만든 뒤 Hierarchy 우클릭 → 3D Object → Plane을 생성하고 Layer를 Ground로 지정한다. Hierarchy 우클릭 → 3D Object → Cube를 2개 만들고 Layer를 Pickable로 지정한다. Cube 하나는 Plane 위에, 다른 하나는 살짝 띄워서 배치한다.
1using UnityEngine;
2
3public class SceneBootstrapForPick : MonoBehaviour
4{
5 [SerializeField] private Camera targetCamera;
6
7 private void Reset()
8 {
9 targetCamera = Camera.main;
10 }
11
12 private void Start()
13 {
14 if (targetCamera != null)
15 {
16 targetCamera.transform.position = new Vector3(0f, 6f, -8f);
17 targetCamera.transform.rotation = Quaternion.Euler(25f, 0f, 0f);
18 }
19 }
20}카메라 위치가 엉뚱하면 클릭 테스트가 실패한 것처럼 보인다. 빈 오브젝트를 만들고(SceneBootstrap), 이 스크립트를 붙이면 Play 시 카메라가 Plane과 Cube를 내려다보게 고정된다. Game 뷰에서 Cube가 화면 중앙 근처에 보이면 준비가 끝이다.
2단계: 클릭 선택 스크립트 붙이기(UI 오클루전 포함)
Hierarchy 우클릭 → Create Empty로 빈 오브젝트를 만들고 이름을 ClickPicker로 한다. Add Component로 스크립트 ClickPickerRaycast를 붙인다. Inspector에서 Target Camera에 Main Camera를 드래그한다. Pick Mask는 Pickable만 체크한다. 이 한 번의 체크로 "바닥이 먼저 맞는다" 문제가 사라진다.
UI가 있는 게임이라면 클릭이 UI 위인지 먼저 걸러야 한다. Hierarchy 우클릭 → UI → Canvas 생성(자동으로 EventSystem도 생긴다). Canvas 아래에 UI → Button을 만든다. 버튼을 화면 한쪽에 두고, 버튼 위를 클릭했을 때 오브젝트 선택 로그가 찍히면 버그가 재현된 상태다.
1using UnityEngine;
2using UnityEngine.EventSystems;
3
4public class ClickPickerRaycast : MonoBehaviour
5{
6 [Header("References")]
7 [SerializeField] private Camera targetCamera;
8
9 [Header("Pick Settings")]
10 [SerializeField] private LayerMask pickMask;
11 [SerializeField] private float maxDistance = 2000f;
12
13 [Header("Debug")]
14 [SerializeField] private bool drawRay = true;
15
16 private void Reset()
17 {
18 targetCamera = Camera.main;
19 pickMask = ~0;
20 }
21
22 private void Update()
23 {
24 if (!Input.GetMouseButtonDown(0)) return;
25
26 if (EventSystem.current != null && EventSystem.current.IsPointerOverGameObject())
27 {
28 Debug.Log("Pick blocked by UI");
29 return;
30 }
31
32 if (targetCamera == null) return;
33
34 Ray ray = targetCamera.ScreenPointToRay(Input.mousePosition);
35 if (drawRay) Debug.DrawRay(ray.origin, ray.direction * 30f, Color.cyan, 1f);
36
37 if (!Physics.Raycast(ray, out RaycastHit hit, maxDistance, pickMask, QueryTriggerInteraction.Ignore))
38 {
39 Debug.Log("Pick miss");
40 return;
41 }
42
43 Debug.Log("Pick hit collider=" + hit.collider.name + " layer=" + hit.collider.gameObject.layer);
44 }
45}Play 후 버튼 위를 클릭하면 "Pick blocked by UI"가 찍혀야 한다. 버튼이 아닌 Cube를 클릭하면 "Pick hit collider=Cube"가 찍힌다. Debug.DrawRay는 Scene 뷰에서 시각적으로 레이가 어디로 날아갔는지 보여준다. 클릭이 씹히는 느낌이 들 때, 레이가 카메라 뒤로 나가거나 엉뚱한 방향으로 나가는지 바로 확인할 수 있다.
3단계: 선택 대상 컴포넌트 분리와 GetComponent 비용 줄이기
실무에서는 콜라이더 이름을 로그 찍는 수준에서 끝나지 않는다. 선택된 오브젝트의 스크립트(예: Selectable)를 찾아서 하이라이트를 켠다. 이때 초보가 가장 많이 하는 패턴이 hit.collider.GetComponent<Selectable>()를 Update마다 호출하는 방식이다. 선택 시점은 클릭 1회인데, 마우스 버튼을 누르고 있는 동안 매 프레임 호출하는 코드가 슬쩍 들어가면 비용이 쌓인다.
Hierarchy에서 Cube 중 하나를 펼쳐서 자식 오브젝트를 만든다(Hierarchy 우클릭 → Create Empty, Cube 아래로 드래그). 자식에 BoxCollider를 붙이고, 루트 Cube에 SelectableTarget 스크립트를 붙인다. 클릭은 자식 콜라이더가 맞지만 선택은 루트가 되어야 하는 상황을 일부러 만든다. 이 구조가 실제 프로젝트에서 가장 흔한 형태다(본체는 루트, 히트박스는 자식).
1using UnityEngine;
2
3public class SelectableTarget : MonoBehaviour
4{
5 [SerializeField] private Renderer cachedRenderer;
6 [SerializeField] private Color selectedColor = Color.yellow;
7 private Color originalColor;
8
9 private void Awake()
10 {
11 if (cachedRenderer == null) cachedRenderer = GetComponentInChildren<Renderer>();
12 if (cachedRenderer != null) originalColor = cachedRenderer.material.color;
13 }
14
15 public void SetSelected(bool selected)
16 {
17 if (cachedRenderer == null) return;
18 cachedRenderer.material.color = selected ? selectedColor : originalColor;
19 }
20}Awake에서 Renderer를 캐싱하면 클릭할 때마다 GetComponentInChildren을 반복하지 않는다. GetComponent 계열은 네이티브 쪽 컴포넌트 리스트를 순회하고, C#으로 결과를 넘기는 과정이 들어간다. 클릭 1회당 1번이면 문제가 없지만, 드래그 선택 같은 기능으로 확장되면 프레임당 수십~수백 번 호출로 바뀐다.
1using UnityEngine;
2using UnityEngine.EventSystems;
3
4public class ClickPickerWithSelection : MonoBehaviour
5{
6 [SerializeField] private Camera targetCamera;
7 [SerializeField] private LayerMask pickMask;
8 [SerializeField] private float maxDistance = 2000f;
9
10 private SelectableTarget current;
11
12 private void Reset()
13 {
14 targetCamera = Camera.main;
15 pickMask = ~0;
16 }
17
18 private void Update()
19 {
20 if (!Input.GetMouseButtonDown(0)) return;
21 if (EventSystem.current != null && EventSystem.current.IsPointerOverGameObject()) return;
22 if (targetCamera == null) return;
23
24 Ray ray = targetCamera.ScreenPointToRay(Input.mousePosition);
25 if (!Physics.Raycast(ray, out RaycastHit hit, maxDistance, pickMask, QueryTriggerInteraction.Ignore))
26 {
27 SetCurrent(null);
28 return;
29 }
30
31 SelectableTarget next = hit.collider.GetComponentInParent<SelectableTarget>();
32 SetCurrent(next);
33 }
34
35 private void SetCurrent(SelectableTarget next)
36 {
37 if (current == next) return;
38 if (current != null) current.SetSelected(false);
39 current = next;
40 if (current != null) current.SetSelected(true);
41 }
42}GetComponentInParent는 콜라이더가 자식에 있어도 루트 SelectableTarget을 찾는다. 이 코드를 실행하면 클릭한 큐브만 노란색으로 바뀌고, 빈 바닥을 클릭하면 선택이 해제된다. 선택 상태를 SetCurrent로 모아둔 이유는 "같은 대상 재선택"에서 불필요한 머티리얼 변경을 막기 위해서다. 머티리얼 접근은 에디터에서 특히 비용이 크다.
심화 활용
한 문단 요약: 클릭 선택의 안정성은 ScreenPointToRay보다 LayerMask와 UI 차단, 그리고 콜라이더-루트 구조( GetComponentInParent )에서 결정되는 비중이 더 크다. 성능은 Raycast 호출 횟수와 후보 콜라이더 수(마스크)로 갈린다.
패턴 1: RaycastNonAlloc로 힙 할당 없이 다중 히트 처리
단일 Raycast는 out RaycastHit 하나만 받는다. 하지만 실무에서는 "가장 가까운 Pickable"을 찾고 싶다. 예를 들어 투명한 FX 콜라이더(무시) 뒤에 실제 유닛 콜라이더가 있다. RaycastAll은 배열을 새로 만들거나 내부에서 결과 배열을 만들어 반환해 GC Alloc이 생길 수 있다. NonAlloc은 호출자가 버퍼를 들고 있고, 네이티브가 그 버퍼에 결과를 채운다.
1using UnityEngine;
2using UnityEngine.EventSystems;
3
4public class ClickPickerNonAlloc : MonoBehaviour
5{
6 [SerializeField] private Camera targetCamera;
7 [SerializeField] private LayerMask pickMask;
8 [SerializeField] private float maxDistance = 2000f;
9 [SerializeField] private int bufferSize = 16;
10
11 private RaycastHit[] hits;
12
13 private void Awake()
14 {
15 hits = new RaycastHit[Mathf.Max(1, bufferSize)];
16 if (targetCamera == null) targetCamera = Camera.main;
17 }
18
19 private void Update()
20 {
21 if (!Input.GetMouseButtonDown(0)) return;
22 if (EventSystem.current != null && EventSystem.current.IsPointerOverGameObject()) return;
23 if (targetCamera == null) return;
24
25 Ray ray = targetCamera.ScreenPointToRay(Input.mousePosition);
26 int count = Physics.RaycastNonAlloc(ray, hits, maxDistance, pickMask, QueryTriggerInteraction.Ignore);
27
28 SelectableTarget best = null;
29 float bestDist = float.MaxValue;
30
31 for (int i = 0; i < count; i++)
32 {
33 var h = hits[i];
34 var t = h.collider.GetComponentInParent<SelectableTarget>();
35 if (t == null) continue;
36 if (h.distance < bestDist) { bestDist = h.distance; best = t; }
37 }
38
39 Debug.Log(best != null ? ("Pick best=" + best.name + " dist=" + bestDist) : "Pick none");
40 }
41}bufferSize를 너무 작게 두면 count가 버퍼 크기에서 잘리고, 뒤에 있는 히트를 잃는다. 이런 버그는 "가끔 선택이 안 됨"으로만 보고돼서 지옥이 열린다. 처음에 나도 이 케이스를 못 보고 2시간 동안 레이어/콜라이더만 의심했다. Profiler에서 GC Alloc은 0인데도 선택이 불안정한 이유가 버퍼 컷이었다. 해결은 버퍼를 넉넉히 잡거나, count == hits.Length일 때 경고 로그를 남기는 방식이다.
패턴 2: 클릭 선택과 드래그 선택을 분리(프레임당 Raycast 1회 제한)
드래그로 마우스를 움직이는 동안 매 프레임 Raycast를 하면, 씬에 콜라이더가 많은 경우 Update 비용이 눈에 띄게 오른다. 특히 모바일에서 60fps 목표라면 프레임당 물리 쿼리는 1~2회로 제한하는 게 안전하다. 클릭(Down)에서 1회, 드래그 중에는 일정 주기(예: 0.05초)로만 쿼리하는 식으로 설계하면 체감은 유지하면서 비용을 줄인다.
1using UnityEngine;
2using UnityEngine.EventSystems;
3
4public class DragHoverPicker : MonoBehaviour
5{
6 [SerializeField] private Camera targetCamera;
7 [SerializeField] private LayerMask pickMask;
8 [SerializeField] private float maxDistance = 2000f;
9 [SerializeField] private float hoverInterval = 0.05f;
10
11 private float nextHoverTime;
12
13 private void Awake()
14 {
15 if (targetCamera == null) targetCamera = Camera.main;
16 }
17
18 private void Update()
19 {
20 if (EventSystem.current != null && EventSystem.current.IsPointerOverGameObject()) return;
21 if (targetCamera == null) return;
22
23 bool dragging = Input.GetMouseButton(0);
24 if (!dragging) return;
25
26 if (Time.unscaledTime < nextHoverTime) return;
27 nextHoverTime = Time.unscaledTime + hoverInterval;
28
29 Ray ray = targetCamera.ScreenPointToRay(Input.mousePosition);
30 if (Physics.Raycast(ray, out RaycastHit hit, maxDistance, pickMask, QueryTriggerInteraction.Ignore))
31 {
32 Debug.Log("Hover=" + hit.collider.name + " t=" + Time.unscaledTime);
33 }
34 }
35}Time.unscaledTime을 쓴 이유는 일시정지(Time.timeScale=0)에서도 UI/선택 입력이 동작해야 하는 게임이 많기 때문이다. 이 코드를 실행하면 드래그 중에 0.05초마다 Hover 로그가 찍힌다. 프레임마다 찍히지 않아서 콘솔도 덜 더럽고, 물리 쿼리 횟수도 제한된다.
내 흑역사 하나. 예전에 선택 시스템이 "가끔" 바닥을 선택했다. 레이어 마스크는 제대로 걸었는데도 그랬다. 원인은 바닥에 MeshCollider가 있고, Pickable 오브젝트에도 MeshCollider가 있었다. MeshCollider는 삼각형 단위로 레이 테스트 비용이 커서, 프레임이 순간적으로 밀리면 클릭 프레임에서 입력 타이밍이 엇나가고, 다음 프레임에 바닥 쪽으로 레이가 찍히는 듯한 착시가 생겼다. Profiler에서 Physics.Raycast가 3~6ms 튀는 프레임이 있었다.
해결은 두 가지였다. 첫째, 선택용 콜라이더를 단순화(BoxCollider/CapsuleCollider)하고 MeshCollider는 실제 충돌에만 남겼다. 둘째, 선택은 클릭 Down 프레임에만 수행하고, 하이라이트 갱신은 별도 타이머로 분리했다. 그 뒤로 "가끔"이 사라졌다. 이 케이스는 코드가 아니라 콜라이더 형태와 쿼리 비용이 원인이었다.
자주 하는 실수
1) LayerMask를 안 걸어서 바닥이 먼저 맞는 문제
증상: 큐브를 클릭해도 콘솔에 Plane이 찍히거나, 선택 하이라이트가 바닥에 붙는다. 씬에서 캐릭터 위를 눌렀는데도 이동 지점만 찍히는 식으로 보인다.
원인: Physics.Raycast는 기본적으로 모든 레이어를 대상으로 쿼리한다. 레이는 가장 가까운 콜라이더부터 맞기 때문에, 바닥이 화면 대부분을 덮고 있으면 거의 항상 바닥이 먼저 히트된다.
해결: 선택 대상 레이어(Pickable)를 분리하고, Raycast의 layerMask에 그 레이어만 포함한다. Inspector에서 ClickPickerRaycast의 Pick Mask에서 Pickable만 체크하면 재현이 즉시 사라진다.
2) UI 위 클릭인데도 월드 오브젝트가 선택됨
증상: 버튼을 클릭하면 버튼 이벤트도 실행되고, 동시에 월드 오브젝트 선택 로그도 찍힌다. QA가 "UI 누르면 뒤에 있는 유닛이 선택된다"고 보고한다.
원인: EventSystem.current.IsPointerOverGameObject 체크가 없다. 또는 Canvas는 있는데 EventSystem이 씬에 없어서 EventSystem.current가 null이다. 씬을 Additive로 로딩할 때 EventSystem이 중복/누락되는 것도 흔하다.
해결: Hierarchy에 EventSystem이 존재하는지 확인한다(Hierarchy 검색창에 EventSystem). 클릭 처리 전에 IsPointerOverGameObject로 차단한다. 멀티터치라면 터치 fingerId 기반 체크로 확장한다.
3) 콜라이더는 맞는데 원하는 스크립트를 못 찾음(null)
증상: Raycast는 hit되는데 GetComponent<SelectableTarget>()가 null이다. 콘솔에 "Pick hit collider=HitBox"만 찍히고 선택이 안 된다.
원인: 콜라이더가 자식 오브젝트에 있고, SelectableTarget은 루트에 붙어 있다. hit.collider.gameObject는 자식이라서 같은 오브젝트에 컴포넌트가 없다.
해결: hit.collider.GetComponentInParent<SelectableTarget>()를 사용한다. 반대로 루트는 비선택, 특정 자식만 선택이라면 GetComponentInChildren로 방향을 바꾼다. 선택 구조를 프로젝트 규칙으로 고정하는 게 디버깅 비용을 줄인다.
4) 트리거 콜라이더가 클릭을 가로챔
증상: 유닛 위에 있는 감지 범위(Trigger Sphere)만 선택되고, 실제 유닛 본체는 선택되지 않는다. 로그에는 Trigger 이름만 계속 찍힌다.
원인: Raycast 기본은 트리거도 히트 대상이다(전역 설정과 QueryTriggerInteraction에 따라 다름). 감지용 트리거가 본체보다 카메라에 더 가깝거나 더 큰 경우 레이가 먼저 맞는다.
해결: QueryTriggerInteraction.Ignore를 선택 쿼리에 적용한다. 트리거도 선택해야 한다면 레이어를 분리하고 NonAlloc로 여러 히트를 받아서 "선택 우선순위"를 코드로 정한다.
5) RaycastAll로 GC Alloc이 튀고 프레임 드랍
증상: 클릭하거나 드래그할 때마다 Profiler에서 GC Alloc이 증가하고, 일정 시간 후 GC.Collect가 발생하면서 프레임이 끊긴다. 에디터에서는 티가 덜 나다가 모바일에서 바로 터진다.
원인: RaycastAll은 결과 배열을 생성/반환하는 경로가 있어 호출 패턴에 따라 할당이 생길 수 있다. 게다가 hit마다 문자열 로그를 만들면 추가 할당이 겹친다.
해결: RaycastNonAlloc로 버퍼를 재사용한다. 로그는 개발 중에만 켜고, 문자열 결합을 줄인다. Profiler에서 Physics.Raycast 계열과 GC Alloc을 함께 본 뒤, 먼저 할당을 없애고 다음으로 쿼리 횟수를 줄인다.
1using UnityEngine;
2
3public class TriggerHitExample : MonoBehaviour
4{
5 [SerializeField] private Camera targetCamera;
6 [SerializeField] private LayerMask mask = ~0;
7
8 private void Awake()
9 {
10 if (targetCamera == null) targetCamera = Camera.main;
11 }
12
13 private void Update()
14 {
15 if (!Input.GetMouseButtonDown(0) || targetCamera == null) return;
16
17 Ray ray = targetCamera.ScreenPointToRay(Input.mousePosition);
18
19 bool collide = Physics.Raycast(ray, out RaycastHit a, 1000f, mask, QueryTriggerInteraction.Collide);
20 bool ignore = Physics.Raycast(ray, out RaycastHit b, 1000f, mask, QueryTriggerInteraction.Ignore);
21
22 Debug.Log("Collide=" + (collide ? a.collider.name : "none") + " / Ignore=" + (ignore ? b.collider.name : "none"));
23 }
24}트리거가 섞인 씬에서 이 코드를 돌리면 Collide와 Ignore의 결과가 달라진다. 같은 화면을 클릭했는데 결과가 바뀌는 걸 직접 보면, "왜 선택이 뒤죽박죽인지"가 감각적으로 이해된다. 선택 시스템은 게임 규칙이므로, 트리거를 선택 대상으로 포함할지부터 팀 규칙으로 박아야 한다.
성능 최적화 체크리스트
- Pickable/Ground/UIBlock 등 레이어를 목적별로 분리하고, Raycast layerMask에 Pickable만 포함한다
- UI가 있는 씬에서는 EventSystem 존재 여부를 확인하고, IsPointerOverGameObject로 월드 선택을 차단한다
- QueryTriggerInteraction 정책을 정한다(선택은 Ignore가 기본, 필요 시 Collide + 우선순위 로직)
- 콜라이더가 자식에 있는 구조를 기본으로 가정하고 GetComponentInParent로 선택 대상을 찾는다
- 선택 대상 컴포넌트(Renderer, Outline 등)는 Awake에서 캐싱하고 클릭 시점에만 토글한다
- 드래그/호버는 프레임당 Raycast를 제한한다(시간 간격, 거리 변화 임계치, 입력 상태 기반)
- RaycastAll 대신 RaycastNonAlloc을 사용하고, 결과 버퍼 크기 부족(count==bufferSize) 경고를 넣는다
- MeshCollider를 선택용으로 쓰지 않는다(가능하면 Box/Capsule로 단순화, 선택 전용 히트박스 분리)
- Transform을 Update에서 직접 움직인 직후 선택이 필요하면 SyncTransforms를 제한적으로 사용하거나 Rigidbody 이동으로 전환한다
- Profiler에서 Physics.Raycast 시간(ms)과 GC Alloc(B) 원인을 분리해서 본다(레이캐스트 vs Debug.Log 문자열)
- 멀티카메라(미니맵/시네카메라) 환경에서는 어떤 카메라로 ScreenPointToRay를 만들지 규칙을 고정한다
- 선택 우선순위(유닛 > 아이템 > 바닥 등)를 레이어/거리/커스텀 점수로 명시하고 테스트 씬을 만든다
자주 묻는 질문
ScreenPointToRay는 내부적으로 어떤 계산을 하고, 왜 카메라가 꼭 필요함?
ScreenPointToRay는 화면 픽셀 좌표를 카메라의 투영 공간으로 역변환해서 월드 레이를 만든다. 카메라가 필요한 이유는 같은 화면 좌표라도 카메라의 위치/회전/투영(Perspective/Orthographic)/클리핑 플레인에 따라 월드에서의 방향 벡터가 달라지기 때문이다. 엔진 내부에서는 카메라의 view/projection 행렬을 기반으로 NDC로 정규화한 뒤 역행렬을 적용해 월드 방향을 만든다. 다음 학습 키워드는 View Matrix, Projection Matrix, NDC, 역투영(Unproject)이다.
Physics.Raycast는 Update에서 호출해도 안전함? FixedUpdate에 넣어야 하는 거 아님?
Raycast는 시뮬레이션을 진행하는 함수가 아니라 물리 월드에 대한 쿼리다. 그래서 Update에서 호출해도 동작한다. 다만 FixedUpdate에서 물리 시뮬레이션이 진행되고, Transform 변경이 물리 콜라이더에 반영되는 타이밍이 프레임 경계와 다를 수 있다. Update에서 Transform을 직접 바꾼 직후 Raycast를 하면 한 프레임 이전 상태를 읽는 듯한 현상이 나올 수 있다. 이 경우 Rigidbody 이동으로 물리 동기화를 맞추거나, 필요한 프레임에만 Physics.SyncTransforms를 고려한다. 다음 학습 키워드는 PlayerLoop, FixedUpdate, Physics.SyncTransforms, Rigidbody interpolation이다.
LayerMask는 단순 필터인데 성능에 왜 영향이 큼?
레이캐스트는 먼저 브로드페이즈에서 후보 콜라이더를 추린 뒤, 좁은 단계에서 정확한 교차 테스트를 한다. LayerMask는 이 후보 추리기 단계에서 적용되므로, 마스크를 걸면 애초에 후보 목록에 들어가지 않는 콜라이더가 늘어난다. 후보가 줄면 좁은 단계 교차 테스트 수가 줄고, 특히 MeshCollider 같은 비싼 콜라이더가 제외되면 ms 단위로 차이가 난다. 태그 비교로 사후 필터링하면 이미 네이티브에서 교차 테스트 비용을 치른 뒤라서 성능 이득이 없다. 다음 학습 키워드는 broadphase/narrowphase, PhysX scene query, collider complexity다.
RaycastAll과 RaycastNonAlloc 중 어떤 걸 써야 함?
선택이 클릭 1회처럼 드물고 결과가 1개면 Raycast로 충분하다. 여러 히트를 받아 우선순위를 정해야 하거나, 드래그/호버처럼 호출 빈도가 높다면 RaycastNonAlloc이 유리하다. RaycastAll은 상황에 따라 결과 배열 생성/반환 경로로 GC Alloc이 생길 수 있고, 누적되면 GC로 프레임 드랍이 나온다. NonAlloc은 호출자가 RaycastHit[] 버퍼를 재사용하므로 힙 할당을 통제할 수 있다. 대신 버퍼가 작으면 결과가 잘려서 선택이 가끔 실패한다. 다음 학습 키워드는 GC Alloc, Profiler, RaycastHit buffer sizing이다.
UI 위에서 월드 선택이 같이 되는 문제는 왜 자주 터짐?
UI 입력과 월드 입력은 서로 다른 시스템이 처리한다. UI는 EventSystem + GraphicRaycaster가 화면 좌표로 UI 그래픽을 레이캐스트하고, 월드는 Camera.ScreenPointToRay + Physics.Raycast로 물리 월드를 레이캐스트한다. 둘은 자동으로 서로를 막아주지 않는다. 그래서 클릭 처리 코드가 UI 차단 체크 없이 돌아가면 버튼을 눌러도 월드가 같이 반응한다. Additive 씬 로딩에서 EventSystem이 중복되거나 누락되면 IsPointerOverGameObject 자체가 무력화된다. 다음 학습 키워드는 Unity UI EventSystem, GraphicRaycaster, Input module, 씬 로딩과 싱글톤 EventSystem이다.
GetComponent를 클릭 때마다 호출해도 괜찮지 않나? 왜 캐싱 이야기가 나옴?
클릭 1회당 1~2번 GetComponent는 대부분 문제 없다. 하지만 기능이 커지면 클릭이 아니라 호버/드래그/박스 선택으로 확장되고, 그 순간 GetComponent가 Update에서 프레임당 수십~수백 번 호출되는 구조로 변한다. GetComponent는 네이티브 쪽 컴포넌트 목록을 탐색하고 C#으로 결과를 넘기는 경로라서 반복 호출 비용이 누적된다(프로젝트 규모에 따라 0.01~0.1ms 단위로 보이는 경우가 있다). Awake에서 Renderer/Selectable을 캐싱하면 이 경로를 제거할 수 있다. 다음 학습 키워드는 component lookup cost, caching pattern, Profiler Timeline이다.
클릭이 가끔 씹히거나 한 프레임 늦게 맞는 느낌이 듦. 어디부터 의심해야 함?
첫 번째로 입력 처리 위치를 본다. 클릭 전이(GetMouseButtonDown)는 Update 프레임 경계에 맞춰 읽어야 해서 FixedUpdate에 넣으면 누락될 수 있다. 두 번째로 물리 동기화 타이밍을 본다. Update에서 Transform을 직접 움직인 직후 Raycast를 하면 물리 콜라이더가 아직 이전 위치일 수 있다. 세 번째로 콜라이더 복잡도를 본다. MeshCollider가 많거나 삼각형이 큰 모델은 Raycast 비용이 튀어 프레임이 밀리고, 그 결과 클릭 시점이 흔들리는 착시가 생긴다. Profiler에서 Physics.Raycast ms 스파이크와 GC Alloc, 그리고 Debug.Log로 인한 할당을 분리해서 확인한다. 다음 학습 키워드는 Input timing, Physics.SyncTransforms, MeshCollider raycast cost, frame spike analysis다.