3. Unity Raycast로 클릭·터치 입력 감지 구현과 엔진 내부 흐름
Unity Raycast로 클릭·터치 입력을 감지하는 방법과 C#→네이티브 물리 쿼리 흐름, PlayerLoop 타이밍, GC/성능 함정을 함께 다룬다. 레이어 마스크와 UI 충돌도 포함한다. 수치 기반 최적화 기준 제공한다. 실습 코드 포함한다. 모바일 대응 포함.
Unity Raycast로 클릭·터치 입력 감지 구현과 엔진 내부 흐름
게임 만들다 겪는 사고로 시작한다. 버튼을 누르면 오브젝트가 선택돼야 하는데, 어떤 폰에서는 터치가 씹히고 에디터에서는 잘 된다. 더 골치 아픈 건 UI 위를 누르면 월드 오브젝트까지 같이 선택되는 버그다. Raycast 한 줄이면 끝일 것 같지만, 실제로는 입력 수집 타이밍과 카메라 좌표 변환, 물리 월드 동기화, 레이어 필터링이 엮여서 재현 조건이 생긴다. 이 글은 왜 그런지 엔진 관점에서 설명하고, 실행했을 때 콘솔과 Scene 뷰에서 어떤 결과가 나오는지로 확인한다.
핵심 개념
Raycast는 화면의 클릭/터치 같은 2D 입력을 3D(또는 2D) 월드의 충돌체와 연결하는 다리 역할을 한다. 입력은 스크린 좌표(px)로 들어오고, 월드는 물리 엔진이 가진 공간 분할 구조(브로드페이즈)에서 충돌체를 찾는다. 그래서 클릭 감지는 단순한 좌표 비교가 아니라, 카메라의 투영 행렬과 물리 월드의 상태가 모두 맞아야 한다.
용어를 5개만 정확히 잡고 가면 디버깅 속도가 확 올라간다. Ray는 원점(origin)과 방향(direction)을 가진 반직선이다. ScreenPointToRay는 스크린 좌표를 카메라의 뷰/프로젝션 역변환으로 월드 Ray로 만든다. RaycastHit는 네이티브 물리 쿼리 결과를 C#으로 복사해 담는 구조체다. LayerMask는 물리 쿼리에서 후보를 줄이는 비트마스크다. EventSystem은 UI가 입력을 소비했는지 판단하는 별도 레이캐스트(GraphicRaycaster)를 가진다.
왜 LayerMask가 필수인지가 실무에서 제일 많이 터진다. Raycast는 기본값이면 거의 모든 레이어를 대상으로 브로드페이즈 후보를 만든다. 후보가 많아지면 쿼리 시간이 늘고, 무엇보다 '보이지 않는 트리거 콜라이더' 같은 의도치 않은 오브젝트가 먼저 맞아서 클릭이 먹통처럼 보인다. 클릭이 씹히는 게 아니라, 다른 콜라이더를 맞고 있는 경우가 많다.
입력 API는 두 계열이 있다. Input.GetMouseButtonDown/Touch는 구 Input Manager 기반이고, 새 Input System은 이벤트 기반으로 상태를 쌓아 둔 뒤 프레임 단위로 소비한다. 어느 쪽이든 공통점은 PlayerLoop 중 입력 업데이트 단계에서 상태가 갱신되고, Update에서 읽는 형태라는 점이다. FixedUpdate에서 클릭을 읽으면 프레임과 물리 틱이 어긋나서 누락처럼 보일 수 있다.
1using UnityEngine;
2
3public class BasicRaycastClick : MonoBehaviour
4{
5 [SerializeField] private Camera targetCamera;
6 [SerializeField] private LayerMask hitMask = ~0;
7 [SerializeField] private float maxDistance = 200f;
8
9 private void Reset()
10 {
11 targetCamera = Camera.main;
12 }
13
14 private void Update()
15 {
16 if (!Input.GetMouseButtonDown(0)) return;
17 if (targetCamera == null) return;
18
19 Ray ray = targetCamera.ScreenPointToRay(Input.mousePosition);
20 if (Physics.Raycast(ray, out RaycastHit hit, maxDistance, hitMask, QueryTriggerInteraction.Ignore))
21 {
22 Debug.Log($"Hit: {hit.collider.name} point={hit.point} dist={hit.distance:F2}");
23 }
24 else
25 {
26 Debug.Log("Hit: none");
27 }
28 }
29}이 스크립트를 빈 GameObject에 붙이고 Play를 누른 뒤, 콜라이더가 붙은 Cube를 클릭하면 콘솔에 Hit: Cube...가 찍힌다. 아무것도 없는 곳을 클릭하면 Hit: none이 찍힌다. 로그가 찍히는지부터 확인하는 이유는, 입력이 들어오는지(입력 단계)와 물리 쿼리가 맞는지(물리 단계)를 분리해 실패 지점을 좁히기 위해서다.
엔진 관점에서의 내부 동작
Update에서 Input.GetMouseButtonDown을 읽는 순간, C#이 즉석에서 OS 이벤트를 뒤지는 구조가 아니다. Unity는 PlayerLoop 초반(플랫폼별 입력 업데이트 단계)에서 OS/플랫폼 이벤트를 수집해 네이티브 입력 상태 버퍼에 적재하고, 스크립트 Update에서는 그 스냅샷을 읽는다. 그래서 같은 프레임 안에서 여러 스크립트가 읽어도 결과가 일관된다.
ScreenPointToRay는 계산만 하는 순수 C# 함수처럼 보이지만, 핵심 데이터(카메라의 projectionMatrix, worldToCameraMatrix, pixelRect 등)는 네이티브 쪽 카메라 상태와 동기화된 값을 사용한다. 카메라가 렌더링 직전에 행렬을 갱신하는 경우가 있어, 렌더 타이밍과 입력 타이밍이 엇갈리면 '한 프레임 늦게' 맞는 것처럼 보이는 사례가 나온다. 특히 카메라를 LateUpdate에서 움직이고 Update에서 레이캐스트하면, 화면에 보이는 위치와 레이가 약간 어긋난다.
Physics.Raycast는 C#에서 보이는 API지만 실제 충돌 판정은 네이티브(C++) 물리 엔진(3D는 PhysX 계열)에서 처리된다. C# 래퍼는 내부 호출(바인딩)을 통해 현재 PhysicsScene의 쿼리 함수로 들어가고, 브로드페이즈에서 후보 AABB를 고른 뒤 내로우페이즈에서 실제 삼각형/프리미티브 교차를 검사한다. RaycastHit는 값 타입이지만, 네이티브 결과를 C# 구조체로 복사하는 마샬링이 발생한다. 단일 hit는 비용이 작지만, 다량 호출 시 누적된다.
PlayerLoop 관점에서 중요한 포인트는 '물리 월드가 최신인가'다. Transform을 움직였다고 즉시 물리 월드의 브로드페이즈가 갱신되는 게 아니다. Unity는 일정 타이밍에 Transform 변경을 물리 엔진으로 동기화한다. 디폴트 설정에서 3D 물리는 FixedUpdate 주기에서 시뮬레이션되고, 그 전후로 Transform/Collider 동기화가 일어난다. Update에서 레이캐스트를 쏘는 건 가능하지만, 같은 프레임에 Transform을 바꾸고 바로 쏘면 설정에 따라 이전 상태를 기준으로 맞는 것처럼 보일 수 있다.
GC 관점에서 Raycast 자체는 관리 힙 할당이 거의 없다. Ray, RaycastHit는 구조체라 스택에 놓인다. 대신 흔한 함정은 RaycastAll이 반환하는 RaycastHit[] 배열이다. 배열은 관리 힙에 할당되며, 클릭 한 번이 아니라 드래그 중 매 프레임 호출하면 GC Alloc이 쌓여서 1~2초마다 GC 스파이크가 튄다. 클릭/터치 입력은 '한 번만' 발생하는 것 같아도, 길게 누르기/드래그 기능을 붙이는 순간 매 프레임 쿼리가 된다.
왜 QueryTriggerInteraction을 명시하는지에도 이유가 있다. 트리거는 물리 충돌 해결(반발/마찰)을 하지 않지만, 쿼리에는 포함될 수 있다. 클릭 판정에서 트리거가 먼저 맞으면, 실제로 클릭해야 할 오브젝트가 뒤에 있어도 못 맞는다. 그래서 클릭은 Ignore로 두는 편이 많고, 의도적으로 트리거를 클릭 대상으로 삼을 때만 Collide로 바꾼다.
1using UnityEngine;
2
3public class PlayerLoopTimingProbe : MonoBehaviour
4{
5 private int _frame;
6
7 private void Awake()
8 {
9 Debug.Log("Awake");
10 }
11
12 private void OnEnable()
13 {
14 Debug.Log("OnEnable");
15 }
16
17 private void Start()
18 {
19 Debug.Log("Start");
20 }
21
22 private void FixedUpdate()
23 {
24 if (_frame < 5) Debug.Log("FixedUpdate");
25 }
26
27 private void Update()
28 {
29 if (_frame < 5) Debug.Log("Update");
30 _frame++;
31 }
32
33 private void LateUpdate()
34 {
35 if (_frame < 5) Debug.Log("LateUpdate");
36 }
37}Play하면 콘솔에 Awake/OnEnable/Start가 먼저 찍히고, 그 뒤 FixedUpdate/Update/LateUpdate 순서가 반복된다. 클릭 입력은 Update에서 읽는 게 일관되고, 카메라를 LateUpdate에서 움직이면 레이 계산도 LateUpdate 이후가 더 자연스럽다. 이 로그를 직접 찍어보면 입력과 카메라 이동, 레이캐스트를 어느 함수에 두어야 화면과 일치하는지 감이 잡힌다.
1using UnityEngine;
2
3public class RayVsMovedTransform : MonoBehaviour
4{
5 [SerializeField] private Camera cam;
6 [SerializeField] private Transform movingTarget;
7 [SerializeField] private float speed = 3f;
8
9 private void Reset()
10 {
11 cam = Camera.main;
12 }
13
14 private void Update()
15 {
16 if (movingTarget != null)
17 movingTarget.position += Vector3.right * (Mathf.Sin(Time.time) * speed) * Time.deltaTime;
18
19 if (Input.GetMouseButtonDown(0) && cam != null)
20 {
21 Ray ray = cam.ScreenPointToRay(Input.mousePosition);
22 bool hit = Physics.Raycast(ray, out RaycastHit rh, 200f);
23 Debug.Log($"Update Raycast hit={hit} name={(hit ? rh.collider.name : "none")}");
24 }
25 }
26
27 private void LateUpdate()
28 {
29 if (Input.GetMouseButtonDown(0) && cam != null)
30 {
31 Ray ray = cam.ScreenPointToRay(Input.mousePosition);
32 bool hit = Physics.Raycast(ray, out RaycastHit rh, 200f);
33 Debug.Log($"LateUpdate Raycast hit={hit} name={(hit ? rh.collider.name : "none")}");
34 }
35 }
36}이 스크립트는 같은 클릭을 Update와 LateUpdate에서 각각 레이캐스트한다. 대상이 움직일 때, 화면에 보이는 위치와 더 잘 맞는 쪽 로그가 생긴다. 카메라/타겟 이동 순서에 따라 둘이 달라진다. 이 차이가 생기는 이유는 렌더링 직전의 최종 행렬이 LateUpdate 이후에 확정되는 경우가 많기 때문이다.
실습하기
1단계: 프로젝트 설정과 입력 전제 만들기
Unity Hub → New project → 3D (Built-in Render Pipeline) 템플릿을 선택한다. 버전은 2022.3 LTS 이상이면 동일하게 따라간다. 프로젝트를 열면 기본 Main Camera와 Directional Light가 있다.
Edit → Project Settings → Physics에서 Queries Hit Triggers가 체크되어 있으면 트리거도 레이캐스트에 맞는다. 클릭 판정은 트리거를 무시하는 편이 많으니, 이 옵션은 그대로 두고 Raycast 호출에서 QueryTriggerInteraction.Ignore를 명시하는 방식으로 제어한다. 전역 옵션을 건드리면 다른 시스템(시야, AI 감지)이 같이 바뀌어 디버깅이 어려워진다.
1using UnityEngine;
2
3public class SceneBootstrap : MonoBehaviour
4{
5 [SerializeField] private Camera cam;
6
7 private void Awake()
8 {
9 if (cam == null) cam = Camera.main;
10 if (cam != null)
11 {
12 cam.transform.position = new Vector3(0f, 6f, -10f);
13 cam.transform.rotation = Quaternion.Euler(20f, 0f, 0f);
14 }
15
16 Debug.Log("Bootstrap ready. Click cubes in Game view.");
17 }
18}이 스크립트를 넣는 이유는 카메라 위치가 애매하면 클릭이 맞았는지 판단이 어려워지기 때문이다. Play하면 콘솔에 Bootstrap ready...가 찍히고, Game 뷰에서 바닥과 큐브가 잘 보이는 구도가 된다.
2단계: 씬 구성(레이어, 콜라이더, UI 겹침) 만들기
Hierarchy 우클릭 → 3D Object → Plane을 만든다. 이름을 Ground로 바꾸고 Transform Position을 (0,0,0)으로 둔다. 그 다음 Hierarchy 우클릭 → 3D Object → Cube를 3개 만든다. 각각 Position을 (-2,0.5,0), (0,0.5,0), (2,0.5,0)로 둔다. Cube에는 기본 BoxCollider가 이미 붙어 있다.
레이어를 분리한다. Project 창에서 아무 오브젝트나 선택 → Inspector 상단 Layer → Add Layer… → User Layer 8에 Clickable을 추가한다. 큐브 3개를 모두 선택하고 Layer를 Clickable로 바꾼다(Apply to children는 큐브 단독이라 상관없다). Ground는 Default로 둔다. 이렇게 해두면 클릭은 큐브만 맞고 바닥은 무시하도록 마스크를 만들 수 있다.
1using UnityEngine;
2
3[RequireComponent(typeof(Collider))]
4public class Clickable : MonoBehaviour
5{
6 [SerializeField] private Color highlightColor = Color.yellow;
7 private Renderer _renderer;
8 private Color _original;
9
10 private void Awake()
11 {
12 _renderer = GetComponent<Renderer>();
13 if (_renderer != null) _original = _renderer.material.color;
14 }
15
16 public void SetHighlighted(bool on)
17 {
18 if (_renderer == null) return;
19 _renderer.material.color = on ? highlightColor : _original;
20 }
21}큐브를 클릭했을 때 눈으로 확인할 피드백이 필요하다. 콘솔 로그만으로도 가능하지만, 하이라이트가 있으면 'UI가 먹었나/레이가 빗나갔나/다른 콜라이더를 맞았나'를 한 프레임에 판단한다. 이 코드에서 GetComponent는 Awake에서 1회만 호출한다. Update에서 매 프레임 GetComponent를 호출하면 네이티브 컴포넌트 배열 탐색 비용이 누적된다.
3단계: 클릭/터치 통합 입력 + Raycast + UI 차단
Hierarchy 우클릭 → Create Empty로 InputManager 오브젝트를 만든다. Inspector에서 Add Component로 아래 스크립트를 붙인다. targetCamera에는 Main Camera를 드래그한다. clickableMask는 Clickable만 포함하도록 드롭다운에서 선택한다. maxDistance는 200으로 둔다.
UI 겹침도 만든다. Hierarchy 우클릭 → UI → Canvas 생성(자동으로 EventSystem도 생성된다). Canvas 하위에 UI → Panel을 만들고, Panel의 RectTransform을 화면 왼쪽 절반 정도 덮게 키운다. Panel의 Image Color 알파를 0.3 정도로 둔다. Play 후 Panel 위를 클릭하면 월드 선택이 막혀야 한다. 막히지 않으면 EventSystem 또는 GraphicRaycaster 설정이 빠진 것이다.
1using System.Collections.Generic;
2using UnityEngine;
3using UnityEngine.EventSystems;
4
5public class RaycastInputManager : MonoBehaviour
6{
7 [Header("Camera")]
8 [SerializeField] private Camera targetCamera;
9
10 [Header("Raycast")]
11 [SerializeField] private LayerMask clickableMask;
12 [SerializeField] private float maxDistance = 200f;
13 [SerializeField] private QueryTriggerInteraction triggerInteraction = QueryTriggerInteraction.Ignore;
14
15 private Clickable _current;
16
17 private void Reset()
18 {
19 targetCamera = Camera.main;
20 }
21
22 private void Update()
23 {
24 if (!TryGetPointerDown(out Vector2 screenPos)) return;
25 if (targetCamera == null) return;
26
27 if (IsPointerOverUI())
28 {
29 Debug.Log("Pointer over UI: world click blocked");
30 return;
31 }
32
33 Ray ray = targetCamera.ScreenPointToRay(screenPos);
34 bool hit = Physics.Raycast(ray, out RaycastHit rh, maxDistance, clickableMask, triggerInteraction);
35
36 if (!hit)
37 {
38 Select(null);
39 Debug.Log("World hit: none");
40 return;
41 }
42
43 Clickable clickable = rh.collider.GetComponent<Clickable>();
44 Select(clickable);
45 Debug.Log($"World hit: {rh.collider.name} layer={LayerMask.LayerToName(rh.collider.gameObject.layer)}");
46 }
47
48 private void Select(Clickable next)
49 {
50 if (_current == next) return;
51 if (_current != null) _current.SetHighlighted(false);
52 _current = next;
53 if (_current != null) _current.SetHighlighted(true);
54 }
55
56 private bool TryGetPointerDown(out Vector2 screenPos)
57 {
58 if (Input.touchCount > 0)
59 {
60 Touch t = Input.GetTouch(0);
61 if (t.phase == TouchPhase.Began)
62 {
63 screenPos = t.position;
64 return true;
65 }
66 }
67
68 if (Input.GetMouseButtonDown(0))
69 {
70 screenPos = Input.mousePosition;
71 return true;
72 }
73
74 screenPos = default;
75 return false;
76 }
77
78 private bool IsPointerOverUI()
79 {
80 if (EventSystem.current == null) return false;
81
82 if (Input.touchCount > 0)
83 return EventSystem.current.IsPointerOverGameObject(Input.GetTouch(0).fingerId);
84
85 return EventSystem.current.IsPointerOverGameObject();
86 }
87}Play 후 Game 뷰에서 큐브를 클릭하면 해당 큐브가 노랗게 바뀌고 콘솔에 World hit: Cube가 찍힌다. Panel 위를 클릭하면 Pointer over UI...가 찍히고 큐브 색이 바뀌지 않는다. UI 차단을 먼저 넣는 이유는, 월드 선택이 UI와 경합하면 "가끔 두 번 선택된다" 같은 재현 어려운 버그가 생기기 때문이다.
심화 활용
한 문단 요약: 클릭/터치 감지는 (1) 입력 스냅샷을 Update에서 읽고 (2) 카메라 행렬로 Ray를 만들고 (3) 네이티브 물리 쿼리로 Collider를 찾고 (4) 레이어/트리거/UI로 필터링하는 파이프라인이다. 버그는 대부분 이 네 단계 중 한 곳의 타이밍/필터 누락에서 터진다.
패턴 1: RaycastNonAlloc + 드래그/홀드 입력에도 GC 0 유지
드래그로 오브젝트를 따라다니는 하이라이트를 만들면 Raycast가 매 프레임 호출된다. 이때 RaycastAll을 쓰면 RaycastHit[]가 매 프레임 할당돼서 Profiler에서 GC Alloc이 1~5KB씩 쌓인다. 모바일 60fps에서 300~600프레임만 쌓여도 GC가 한 번 돌면서 10~30ms 스파이크가 나오는 케이스를 여러 번 봤다.
1using UnityEngine;
2
3public class DragRaycastNonAlloc : MonoBehaviour
4{
5 [SerializeField] private Camera cam;
6 [SerializeField] private LayerMask mask;
7 [SerializeField] private float maxDistance = 200f;
8
9 private readonly RaycastHit[] _hits = new RaycastHit[8];
10 private Clickable _current;
11
12 private void Reset()
13 {
14 cam = Camera.main;
15 }
16
17 private void Update()
18 {
19 bool pressed = Input.GetMouseButton(0) || Input.touchCount > 0;
20 if (!pressed || cam == null) return;
21
22 Vector2 pos = Input.touchCount > 0 ? Input.GetTouch(0).position : (Vector2)Input.mousePosition;
23 Ray ray = cam.ScreenPointToRay(pos);
24
25 int count = Physics.RaycastNonAlloc(ray, _hits, maxDistance, mask, QueryTriggerInteraction.Ignore);
26 if (count <= 0)
27 {
28 Select(null);
29 return;
30 }
31
32 // 가장 가까운 hit 찾기(배열은 정렬되지 않는다)
33 int best = 0;
34 float bestDist = _hits[0].distance;
35 for (int i = 1; i < count; i++)
36 {
37 if (_hits[i].distance < bestDist)
38 {
39 best = i;
40 bestDist = _hits[i].distance;
41 }
42 }
43
44 Clickable c = _hits[best].collider.GetComponent<Clickable>();
45 Select(c);
46 }
47
48 private void Select(Clickable next)
49 {
50 if (_current == next) return;
51 if (_current != null) _current.SetHighlighted(false);
52 _current = next;
53 if (_current != null) _current.SetHighlighted(true);
54 }
55}이 코드를 실행하면 드래그 중에도 하이라이트가 따라다니고, Profiler의 GC Alloc 컬럼이 0B로 유지된다. NonAlloc이 빠른 이유는 네이티브 결과를 관리 힙 배열에 새로 만들지 않고, 호출자가 준 배열에 복사하기 때문이다. 대신 배열 크기(여기서는 8)를 넘는 hit는 잘린다. 실무에서는 클릭 후보가 많은 장면(파편, FX 콜라이더)에서 배열 크기를 넉넉히 잡거나, 레이어로 후보 수를 줄여야 한다.
패턴 2: 클릭 가능한 것만 인터페이스로 추상화 + GetComponent 비용 고정
클릭 대상이 늘어나면 if-else로 타입을 분기하기 시작하고, 어느 순간부터 클릭 로직이 게임 규칙 전체를 끌어안는다. 이때도 Raycast는 Collider를 반환하므로, 결국 GetComponent로 행동을 찾는다. GetComponent는 네이티브에 있는 컴포넌트 리스트를 탐색하고 C# 래퍼를 건네는 과정이 들어간다. 매 프레임 여러 번 호출하면 0.01~0.1ms 단위의 비용이 쌓여서, UI/애니메이션이 많은 프레임에서 1ms 이상 튀는 장면이 나온다(Profiler에서 Script.Update 쪽으로 묻혀 보이는 경우가 많다).
1using UnityEngine;
2
3public interface IClickReceiver
4{
5 void OnClick(RaycastHit hit);
6}
7
8public class ClickReceiverExample : MonoBehaviour, IClickReceiver
9{
10 [SerializeField] private string id = "NPC_01";
11
12 public void OnClick(RaycastHit hit)
13 {
14 Debug.Log($"Clicked receiver id={id} hitPoint={hit.point}");
15 }
16}
17
18public class InterfaceClickDispatcher : MonoBehaviour
19{
20 [SerializeField] private Camera cam;
21 [SerializeField] private LayerMask mask;
22
23 private void Reset()
24 {
25 cam = Camera.main;
26 }
27
28 private void Update()
29 {
30 if (!Input.GetMouseButtonDown(0) || cam == null) return;
31
32 Ray ray = cam.ScreenPointToRay(Input.mousePosition);
33 if (!Physics.Raycast(ray, out RaycastHit hit, 200f, mask, QueryTriggerInteraction.Ignore)) return;
34
35 // TryGetComponent는 예외/캐스팅 비용을 줄이고, 실패 시 할당 없이 false를 준다.
36 if (hit.collider.TryGetComponent<IClickReceiver>(out var receiver))
37 receiver.OnClick(hit);
38 }
39}이 구조로 바꾸면 클릭 로직은 물리 쿼리와 디스패치만 담당하고, 실제 반응은 각 오브젝트가 가진 컴포넌트로 내려간다. 실행하면 클릭한 오브젝트에서 Clicked receiver... 로그가 찍힌다. TryGetComponent는 실패 케이스에서 불필요한 예외/캐스팅 경로를 피하고, 내부적으로도 컴포넌트 탐색을 한 번으로 끝내는 설계라 클릭 대상이 많을수록 이득이 커진다.
처음에 나도 이게 왜 이렇게 동작하는지 몰랐다. 모바일에서만 '터치가 가끔 안 먹는다'는 QA 리포트를 받고, 에디터에서는 재현이 안 됐다. 결국 원인은 UI였다. EventSystem.current.IsPointerOverGameObject()를 마우스 기준으로만 호출해 두고, 터치 fingerId를 넘기지 않아 UI 위 터치가 월드로 새는 상황이었다. 콘솔에는 아무 에러도 없고, 선택이 "가끔" 두 번 바뀌는 것처럼 보여서 3시간 삽질했다.
또 한 번은 클릭이 완전히 먹통이었는데, 원인이 Physics 설정이 아니라 레이어였다. 레이어 마스크를 (1 << LayerMask.NameToLayer("Clickable"))로 만들어 둔 줄 알았는데, 실제 프로젝트에서는 레이어 이름이 Clickables로 바뀌어 NameToLayer가 -1을 반환했다. 그 상태에서 시프트 연산을 하면 마스크가 깨져서 아무것도 안 맞는다. 콘솔에 "World hit: none"만 찍혀서 물리 문제로 오해했는데, Debug.Log로 mask.value를 찍어보니 0이었다.
자주 하는 실수
실수 1: UI 위 클릭이 월드까지 관통한다
증상: Panel이나 버튼 위를 누르면 UI 반응과 동시에 3D 오브젝트도 선택된다. 모바일에서는 특히 심하고, 에디터에서는 가끔만 재현된다.
원인: EventSystem 차단 체크를 하지 않았거나, 터치에서 IsPointerOverGameObject에 fingerId를 넘기지 않았다. 마우스 오버 체크는 포인터 0번을 가정하지만, 터치는 fingerId가 별도라서 다른 결과가 나온다.
해결: Update에서 레이캐스트 전에 UI 차단을 먼저 한다. 터치면 EventSystem.current.IsPointerOverGameObject(touch.fingerId)를 호출한다. Canvas에 GraphicRaycaster가 있는지도 Inspector에서 확인한다.
실수 2: FixedUpdate에서 클릭을 읽어서 입력이 누락된다
증상: 짧게 클릭하면 반응이 없고, 길게 누르면 가끔 반응한다. 특정 프레임레이트에서만 더 심해진다.
원인: 입력 상태는 프레임(Update) 단위로 갱신되는데, FixedUpdate는 물리 틱(기본 0.02s) 기준이다. 클릭은 한 프레임만 true인데 FixedUpdate가 그 프레임을 건너뛰면 영원히 못 읽는다.
해결: GetMouseButtonDown/TouchPhase.Began 같은 엣지 입력은 Update에서만 읽는다. 물리로 힘을 주거나 이동을 적용해야 하면, Update에서 이벤트를 큐에 쌓고 FixedUpdate에서 소비하는 구조로 분리한다.
실수 3: 레이어 마스크를 안 써서 엉뚱한 콜라이더가 먼저 맞는다
증상: 눈에 보이는 오브젝트를 클릭했는데 선택이 안 되거나, 보이지 않는 오브젝트가 선택된다. Scene 뷰에서 Ray를 그려도 이상해 보인다.
원인: Raycast는 가장 가까운 hit를 반환한다. 앞에 투명한 트리거, 큰 바닥 콜라이더, 이펙트용 콜라이더가 있으면 그게 먼저 맞는다. 클릭 대상 레이어를 분리하지 않으면 이런 후보가 계속 섞인다.
해결: Clickable 전용 레이어를 만들고 Raycast에 LayerMask를 필수로 넣는다. 트리거는 QueryTriggerInteraction.Ignore로 끊는다. 필요하면 Physics Debug(상단 메뉴 Window → Analysis → Physics Debugger)로 콜라이더를 시각화한다.
실수 4: RaycastAll을 매 프레임 호출해 GC 스파이크가 난다
증상: Profiler에서 1~2초마다 CPU가 튀고, GC.Collect가 보인다. 드래그 하이라이트나 조준선 같은 기능을 넣은 뒤부터 발생한다.
원인: RaycastAll은 RaycastHit[]를 새로 만들어 반환한다. 배열은 관리 힙 할당이라 누적 후 GC가 돈다. 클릭 한 번이면 티가 안 나지만, 드래그는 초당 60번이라 바로 쌓인다.
해결: Physics.RaycastNonAlloc로 호출자 배열을 재사용한다. 후보 수가 많아지면 레이어로 줄이고, 배열 크기를 늘린다. Profiler에서 GC Alloc이 0B인지 확인한다.
실수 5: Camera.main을 Update에서 계속 호출한다
증상: 기능은 동작하지만, 스펙이 낮은 기기에서 입력 처리만 켜도 프레임이 조금씩 떨어진다. Profiler에서 Script.Update가 두꺼워진다.
원인: Camera.main은 tag가 MainCamera인 카메라를 찾는 탐색을 포함한다. 내부적으로 씬 오브젝트를 찾는 경로가 들어가고, 매 프레임 호출하면 누적 비용이 된다.
해결: Awake/Start에서 Camera 참조를 캐싱하고 SerializeField로 Inspector에서 직접 연결한다. 카메라가 런타임에 바뀌는 게임이면, 변경 시점에만 참조를 갱신한다.
1using UnityEngine;
2
3public class MaskDebugHelper : MonoBehaviour
4{
5 [SerializeField] private LayerMask mask;
6 [SerializeField] private string layerName = "Clickable";
7
8 private void Start()
9 {
10 int layer = LayerMask.NameToLayer(layerName);
11 Debug.Log($"NameToLayer({layerName})={layer}");
12 Debug.Log($"Inspector mask value={mask.value}");
13
14 if (layer < 0)
15 Debug.LogError("Layer name mismatch. Check Project Settings > Tags and Layers.");
16 }
17}성능 최적화 체크리스트
- 클릭/터치 엣지 입력(GetMouseButtonDown, TouchPhase.Began)은 Update에서만 읽는다
- UI가 입력을 소비하는지 EventSystem.IsPointerOverGameObject로 먼저 차단한다(터치는 fingerId 사용)
- Raycast에 LayerMask를 반드시 넣고, 클릭 대상 레이어를 별도로 만든다
- QueryTriggerInteraction.Ignore/Collide를 의도적으로 선택하고 트리거가 클릭을 가로채지 않게 한다
- Camera 참조는 Inspector 연결 또는 Awake 캐싱으로 고정하고 Camera.main을 매 프레임 호출하지 않는다
- 드래그/홀드처럼 매 프레임 쿼리면 RaycastNonAlloc로 GC Alloc 0B를 유지한다
- RaycastAll/Overlap 계열을 프레임 루프에 넣기 전 Profiler에서 Managed Allocations를 확인한다
- 카메라/타겟 이동이 LateUpdate에 있으면 레이캐스트도 LateUpdate로 옮겨 화면과 일치시키는지 검증한다
- Physics Debugger로 콜라이더가 예상대로 배치됐는지(보이지 않는 트리거 포함) 시각적으로 확인한다
- 레이어 이름을 문자열로 만들면 NameToLayer=-1 위험이 있으니, Inspector LayerMask로 설정해 값이 0이 아닌지 확인한다
- Raycast 호출 횟수 목표를 정한다(예: 60fps에서 프레임당 1~5회, 드래그는 1회로 제한)
- Profiler에서 Physics.Raycast가 Script에 묻히면 Profiler Module에 Physics를 켜고 Timeline에서 호출 스택을 본다
자주 묻는 질문
Raycast가 왜 Update에 있어야 하나? FixedUpdate에 두면 물리랑 더 맞는 것 아닌가?
입력 엣지(Down/Began)는 프레임 단위로 1프레임만 true가 된다. Unity는 PlayerLoop에서 입력 상태를 갱신하고, 그 스냅샷을 Update에서 읽는 구조라 Update가 가장 안정적이다. FixedUpdate는 물리 틱 간격으로만 호출되며, 프레임과 독립이라 클릭 프레임을 건너뛰면 신호를 영영 못 읽는다. 실무에서는 Update에서 클릭 이벤트를 큐(예: bool flag, struct)로 저장하고, 물리 상호작용(힘 적용, Rigidbody 이동)은 FixedUpdate에서 큐를 소비한다. 다음 학습 키워드는 PlayerLoop, Input update timing, Fixed timestep이다.
Physics.Raycast는 내부적으로 무엇을 하며, 왜 레이어 마스크가 성능에 영향을 주나?
C#의 Physics.Raycast는 네이티브 물리 엔진 쿼리 함수로 바인딩된다. 네이티브에서는 먼저 브로드페이즈에서 Ray가 통과할 가능성이 있는 충돌체 후보를 AABB 기반으로 추린다. 레이어 마스크는 이 후보 집합을 줄이는 가장 싼 필터다. 후보가 줄면 내로우페이즈(실제 교차 계산)까지 내려가는 오브젝트 수가 줄어 쿼리 시간이 감소한다. 마스크 없이 전 레이어를 대상으로 하면 보이지 않는 트리거/바닥/FX 콜라이더가 후보에 섞여 클릭 버그도 같이 증가한다. 다음 학습 키워드는 broadphase, narrowphase, PhysX scene query, LayerMask bitset이다.
RaycastAll을 쓰면 왜 GC가 생기나? RaycastHit는 struct라서 할당이 없지 않나?
RaycastHit 자체는 값 타입이라 관리 힙 할당이 없다. 문제는 RaycastAll이 반환하는 RaycastHit[] 배열이다. 배열은 참조 타입이라 관리 힙에 새로 할당되고, 매 프레임 호출하면 할당이 누적돼 GC가 주기적으로 실행된다. 클릭 한 번이면 티가 안 나지만 드래그/조준선처럼 초당 60회면 1초에 60개 배열이 생긴다. 해결책은 RaycastNonAlloc로 미리 만든 배열을 재사용하거나, 단일 hit만 필요하면 Raycast(단일)로 바꾸는 것이다. 다음 학습 키워드는 managed heap, GC Alloc in Profiler, NonAlloc APIs, allocation-free patterns이다.
UI 위를 터치했는데도 월드 오브젝트가 선택된다. EventSystem 체크를 했는데 왜 계속 새나?
가장 흔한 원인은 터치에서 fingerId를 넘기지 않은 호출이다. EventSystem.current.IsPointerOverGameObject()는 기본 포인터(마우스) 기준이라, 터치 입력에서는 IsPointerOverGameObject(touch.fingerId)를 써야 한다. 두 번째 원인은 Canvas에 GraphicRaycaster가 없거나, UI 요소의 Raycast Target 옵션이 꺼져 있는 경우다(Image/Text 등에서 Raycast Target 체크). 세 번째는 카메라가 여러 개일 때 UI 이벤트 카메라와 월드 카메라가 다르게 설정돼 좌표가 어긋나는 경우다. 해결 순서는 (1) EventSystem 존재 확인 (2) fingerId 전달 (3) GraphicRaycaster/레이캐스트 타깃 확인 (4) 멀티 카메라면 Canvas Render Mode와 Event Camera 확인이다. 다음 학습 키워드는 EventSystem, GraphicRaycaster, PointerId, Canvas render modes이다.
카메라를 움직이면 클릭 위치가 한 프레임씩 밀리는 느낌이 든다. 왜 그런가?
카메라/타겟 이동이 LateUpdate에서 이뤄지고, 레이캐스트가 Update에서 수행되면 '이 프레임의 화면'과 '이 프레임의 레이'가 서로 다른 변환을 참조할 수 있다. Unity는 렌더링 직전에 최종 행렬을 확정하는 흐름이 있고, 스크립트 실행 순서에 따라 카메라 행렬이 갱신되기 전 값을 사용하면 체감상 한 프레임 늦게 맞는 것처럼 보인다. 해결은 레이캐스트를 카메라 이동 이후(LateUpdate)로 옮기거나, Script Execution Order에서 입력 처리 스크립트를 카메라 이동 뒤로 보내는 방식이다. 다음 학습 키워드는 LateUpdate camera, script execution order, render loop timing, matrix update timing이다.
2D 게임인데 Physics.Raycast를 쓰면 되나? 2D Raycast는 뭐가 다른가?
2D는 Physics2D.Raycast를 써야 한다. 3D 물리(Physics)와 2D 물리(Physics2D)는 완전히 다른 물리 월드(PhysicsScene/PhysicsScene2D)와 콜라이더 타입(BoxCollider vs BoxCollider2D)를 사용한다. 2D에서 3D Raycast를 쏘면 맞을 콜라이더가 없어서 항상 실패한다. 반대로 3D에서 2D Raycast를 쏘면 2D 콜라이더만 맞는다. 내부적으로도 쿼리 함수가 다른 네이티브 엔진으로 바인딩된다. 해결은 프로젝트의 콜라이더 타입을 먼저 확인하고, 해당 물리 API를 일관되게 쓰는 것이다. 다음 학습 키워드는 PhysicsScene vs PhysicsScene2D, collider types, contact filters, raycast layers in 2D이다.
GetComponent를 클릭할 때마다 호출해도 괜찮나? 왜 캐싱이 빠른가?
클릭처럼 저빈도면 GetComponent 비용은 보통 문제되지 않는다. 다만 드래그/홀드에서 매 프레임 hit 대상이 바뀌는 구조면 GetComponent가 초당 수십~수백 번 호출된다. GetComponent는 네이티브에 저장된 컴포넌트 리스트를 탐색하고, C# 래퍼를 돌려주는 경로가 포함된다. 이 탐색 비용이 누적되면 0.01~0.1ms 단위로 쌓여 프레임 예산(16.6ms)에서 무시 못 할 수준이 된다. 캐싱은 탐색을 1회로 줄여 이후 비용을 0에 가깝게 만든다. 실무에서는 클릭 대상 스크립트(Clickable)를 collider에 붙여 두고, hit.collider.GetComponent<Clickable>()를 한 번만 호출하거나, 아예 collider에 직접 참조를 들고 있는 구조로 만든다. 다음 학습 키워드는 component lookup cost, native object binding, caching patterns, TryGetComponent이다.