7. Raycast로 오브젝트 클릭 판별하기: 화면 좌표→월드 충돌 퀴즈
Unity Raycast 클릭 판별을 ScreenPointToRay부터 Physics.Raycast까지 엔진 루프·C++ 바인딩·메모리/GC 관점으로 설명하고, 실습과 성능 체크까지 다룬다.|primaryKeywords|secondaryKeywords
Raycast로 오브젝트 클릭 판별하기: 화면 좌표→월드 충돌 퀴즈
게임 만들다 겪는 사고는 대개 입력에서 터진다. Game 뷰에서 큐브를 클릭했는데 아무 반응이 없거나, UI 버튼 위를 눌렀는데 뒤의 3D 오브젝트가 같이 눌리거나, 카메라를 돌리면 클릭 위치가 미묘하게 빗나간다. 이 문제는 ‘마우스 좌표’가 곧바로 ‘월드 충돌’이 아니기 때문에 생긴다. Unity는 화면 좌표를 카메라의 Ray로 바꾼 뒤, 네이티브 물리 엔진에 쿼리를 던져 충돌을 되돌려준다. 그 중간에서 PlayerLoop 타이밍, Collider/Layer, 트리거 처리, 할당/GC가 얽힌다.
핵심 개념
클릭 판별의 목표는 ‘화면의 한 점’이 ‘월드의 어떤 물체’에 대응되는지 찾는 일이다. 화면 좌표는 2D이고, 월드는 3D라서 깊이 정보가 없다. 그래서 카메라 위치에서 화면의 픽셀을 통과하는 반직선(Ray)을 만들고, 그 Ray가 어떤 Collider와 먼저 만나는지 물어본다. 클릭이 어긋나면 대개 Ray 생성 단계(카메라/좌표계) 또는 물리 쿼리 단계(Collider/Layer/Trigger) 중 하나가 틀어진다.
용어를 엔진 흐름으로 묶어 정의한다. ScreenPointToRay는 C# Camera 래퍼가 네이티브 카메라 매트릭스/뷰포트 정보를 읽어 Ray(원점/방향)를 계산하는 단계다. Physics.Raycast는 C# Physics 래퍼가 네이티브 물리 월드(브로드페이즈/내로페이즈)에 쿼리를 요청하는 단계다. RaycastHit는 결과를 담는 구조체로, 내부적으로 네이티브에서 계산된 충돌 정보가 C#으로 복사된다. LayerMask는 쿼리의 후보를 줄여서 물리 월드 탐색 비용을 줄이는 필터다.
왜 Raycast API가 ‘카메라 → 물리’로 나뉘었나가 중요하다. 카메라는 렌더링 시스템의 일부고, Physics는 물리 시스템의 일부다. Unity는 두 시스템을 느슨하게 결합한다. 화면 좌표를 월드로 바꾸는 책임은 Camera에 있고, 월드에서 충돌을 찾는 책임은 Physics에 있다. 이 분리가 있어야 같은 Ray를 NavMesh, 커스텀 공간 쿼리, 또는 여러 물리 씬(PhysicsScene)에도 재사용할 수 있다.
초보가 가장 빨리 부딪히는 함정은 ‘Collider가 없으면 Raycast에 안 걸린다’는 사실이다. MeshRenderer로 보이는 것과 물리 월드에 존재하는 것은 별개다. 렌더러는 GPU에 그릴 데이터를 갖고 있고, 콜라이더는 물리 엔진이 가진 형태 데이터다. 눈에 보이는데 클릭이 안 되면, 10번 중 7번은 Collider가 없거나 레이어가 마스크에서 제외된 상태다.
1using UnityEngine;
2
3public class BasicClickRaycast : 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, 1000f, pickMask, QueryTriggerInteraction.Ignore))
20 {
21 Debug.Log($"Hit: {hit.collider.name} point={hit.point} distance={hit.distance:F3}");
22 }
23 else
24 {
25 Debug.Log("Hit: none");
26 }
27 }
28}이 스크립트를 빈 GameObject에 붙이고, 씬에 Cube 하나를 만든 뒤 클릭하면 Console에 "Hit: Cube"가 찍힌다. Cube에 Collider가 없으면 "Hit: none"만 찍힌다. 여기서 이미 ‘보이는 것’과 ‘맞는 것’이 분리돼 있다는 사실이 드러난다. Ray는 카메라에서 나가지만, 충돌 후보는 물리 월드에 등록된 Collider들만이다.
엔진 관점에서의 내부 동작
클릭 판별이 Update에서 돌아가는 이유는 입력 샘플링과 렌더링 프레임이 같은 리듬으로 굴기 때문이다. Unity PlayerLoop에서 입력은 프레임 초반에 갱신되고, ScriptRunBehaviourUpdate 단계에서 MonoBehaviour.Update가 호출된다. Input.GetMouseButtonDown은 ‘이번 프레임에 눌림’ 상태를 읽는데, 이 값은 이미 네이티브 입력 시스템이 프레임 시작에 계산해 둔 스냅샷이다. 그래서 Update 밖(예: FixedUpdate)에서 누르면 프레임/물리 틱이 어긋나 클릭이 누락되거나 중복되는 느낌이 난다.
C#에서 Camera.ScreenPointToRay를 호출하면, Camera 클래스는 내부적으로 네이티브 카메라 객체(Transform/Projection/Viewport)를 참조한다. 이 참조는 C# 객체가 네이티브 객체의 핸들을 쥐고 있는 형태다. 호출 시점에 네이티브 쪽에서 카메라 행렬을 가져오거나, 이미 캐시된 행렬을 사용해 스크린 좌표를 NDC로 정규화한 뒤 월드 공간 방향 벡터를 만든다. 여기서 중요한 점은 ‘스크린 좌표’가 Game 뷰 픽셀 기준이라는 점이다. Scene 뷰 클릭 좌표와 혼동하면 Ray가 전혀 다른 방향으로 나간다.
Physics.Raycast는 C# 래퍼가 네이티브 물리 월드에 쿼리를 요청하는 경로다. Unity 물리는 버전에 따라 PhysX 기반이거나, 2D는 Box2D 계열이다. 3D Raycast는 네이티브에서 브로드페이즈(대략적인 후보 찾기) → 내로페이즈(정확한 교차 계산) 순서로 진행된다. LayerMask는 브로드페이즈 후보를 크게 줄인다. 마스크를 ~0(Everything)로 두면 씬이 커질수록 쿼리 비용이 선형으로 튄다.
RaycastHit는 구조체라서 관리 힙에 별도 객체를 만들지 않는다는 점이 초보에게 의외다. out RaycastHit hit는 스택(또는 JIT 최적화된 임시 공간)에 잡히고, 네이티브 결과가 값 복사로 채워진다. 그래서 Raycast 자체가 GC Alloc을 만들지는 않는다. 대신 Debug.Log의 문자열 보간, hit.collider를 통해 UnityEngine.Object를 만지는 과정, GetComponent 호출이 섞이면 Alloc이 생긴다. Profiler에서 GC Alloc이 0인데도 프레임이 느린 경우는 대개 물리 쿼리 비용이 CPU를 먹는 케이스다.
왜 트리거가 기본으로 걸리기도 하고 안 걸리기도 하나가 헷갈린다. Physics.Raycast의 오버로드에는 QueryTriggerInteraction이 들어간다. Ignore로 두면 Is Trigger 체크된 Collider는 후보에서 빠진다. 트리거는 충돌 해결(리졸브)이 아니라 이벤트 감지용이라서, 클릭 판별에서 트리거를 섞으면 ‘보이지 않는 감지 볼륨’이 클릭을 가로채는 버그가 자주 난다. 실무에서는 보통 클릭용 레이어를 따로 두고, 트리거는 Ignore로 두는 편이 디버깅이 쉽다.
내가 처음 이 동작을 정확히 이해 못 했을 때, 클릭이 카메라 회전 후에만 어긋나는 버그를 3시간 잡았다. 원인은 Input.mousePosition을 UI 캔버스 좌표로 착각하고 RectTransformUtility로 변환한 값을 ScreenPointToRay에 넣은 것이었다. Console에는 맞는 좌표처럼 보였지만, 카메라가 Perspective일 때 픽셀 기준이 아닌 좌표를 넣으면 Ray 방향이 틀어진다. Scene 뷰에서 Debug.DrawRay로 확인하니 Ray가 바닥이 아니라 하늘로 쏘고 있었다.
1using UnityEngine;
2
3public class PlayerLoopProbe : 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 Update()
23 {
24 _frame++;
25 if (Input.GetMouseButtonDown(0))
26 Debug.Log($"Update frame={_frame} mouseDown pos={Input.mousePosition}");
27 }
28
29 private void FixedUpdate()
30 {
31 if (Input.GetMouseButtonDown(0))
32 Debug.Log("FixedUpdate saw mouseDown (rare / timing dependent)");
33 }
34}이 스크립트를 빈 오브젝트에 붙이고 Play를 누른 뒤 클릭하면, 거의 항상 Update 쪽 로그만 찍힌다. FixedUpdate 로그가 찍히는 경우가 있더라도 ‘물리 틱과 입력 스냅샷이 우연히 겹친 프레임’일 뿐이다. 입력을 FixedUpdate에서 처리하면 클릭이 씹히는 느낌이 나는 이유가 이 로그로 드러난다.
1using UnityEngine;
2
3public class RayDebugDraw : MonoBehaviour
4{
5 [SerializeField] private Camera targetCamera;
6 [SerializeField] private float rayLength = 50f;
7
8 private void Reset()
9 {
10 targetCamera = Camera.main;
11 }
12
13 private void Update()
14 {
15 if (targetCamera == null) return;
16
17 Ray ray = targetCamera.ScreenPointToRay(Input.mousePosition);
18 Debug.DrawRay(ray.origin, ray.direction * rayLength, Color.cyan);
19
20 if (Input.GetMouseButtonDown(0))
21 {
22 bool hit = Physics.Raycast(ray, out RaycastHit info, rayLength);
23 Debug.Log($"Ray origin={ray.origin} dir={ray.direction} hit={hit}");
24 }
25 }
26}Scene 뷰에서 Gizmos를 켜면 마우스를 움직일 때마다 시안색 Ray가 그려진다. 클릭이 안 맞을 때 이 Ray가 실제로 어디를 향하는지 눈으로 확인 가능하다. Ray가 엉뚱한 방향이면 좌표계 문제고, Ray는 맞는데 히트가 없으면 Collider/Layer/Trigger 문제로 범위가 좁혀진다.
실습하기
1단계: 프로젝트 설정(입력·카메라·물리 기본값 고정)
Unity Hub → New project → 3D (Built-in Render Pipeline)로 만든다. URP도 가능하지만, 초보가 카메라/포스트프로세싱/렌더러 설정에 시간을 쓰기 쉬워 Built-in이 실습에 덜 흔들린다. 에디터 우측 상단 Layout은 Default로 둔다.
Edit → Project Settings → Physics에서 Queries Hit Triggers 값을 확인한다. 이 값이 켜져 있으면 Raycast 기본 오버로드가 트리거를 맞출 수 있어 디버깅이 꼬인다. 실습에서는 끄고, 코드에서 QueryTriggerInteraction을 명시한다. 같은 메뉴에서 Layer Collision Matrix는 건드리지 않는다.
2단계: 씬 구성(클릭 대상과 방해물 만들기)
Hierarchy 우클릭 → 3D Object → Plane을 만든다. 이름을 Ground로 바꾸고 Transform Position을 (0,0,0)으로 둔다. Hierarchy 우클릭 → 3D Object → Cube를 만들고 이름을 TargetCube로 바꾼 뒤 Position을 (0,0.5,0)으로 둔다. Camera는 Main Camera를 선택하고 Position을 (0,3,-6), Rotation을 (20,0,0)으로 맞춘다.
TargetCube를 선택하고 Inspector에서 Box Collider가 있는지 확인한다(기본으로 붙어 있다). 이제 방해물을 하나 만든다. Hierarchy 우클릭 → 3D Object → Sphere를 만들고 이름을 TriggerBubble로 바꾼 뒤 Position을 (0,0.5,-1)로 둔다. Inspector에서 Sphere Collider의 Is Trigger를 체크한다. 이 트리거가 클릭을 가로채는 상황을 일부러 만든다.
3단계: 코드 작성 및 테스트(콘솔 로그로 퀴즈 풀기)
Project 창에서 Assets 우클릭 → Create → C# Script로 ClickPicker를 만든다. 빈 GameObject를 하나 만들고 이름을 InputSystem으로 둔 뒤 ClickPicker를 붙인다. Inspector에서 Target Camera 슬롯에 Main Camera를 드래그한다. Pick Mask는 Everything으로 두고 시작한다.
Play 모드에서 TargetCube를 클릭하면 콘솔에 어떤 오브젝트 이름이 찍히는지 확인한다. TriggerBubble이 앞에 있으면 먼저 맞는다. 그 다음 Pick Mask를 조절해 TriggerBubble 레이어를 제외하면 TargetCube가 찍힌다. 이 과정을 통해 ‘레이어 마스크가 브로드페이즈 후보를 줄인다’는 감각이 생긴다.
1using UnityEngine;
2
3public class ClickPicker : MonoBehaviour
4{
5 [Header("Wiring")]
6 [SerializeField] private Camera targetCamera;
7
8 [Header("Query")]
9 [SerializeField] private float maxDistance = 100f;
10 [SerializeField] private LayerMask pickMask = ~0;
11 [SerializeField] private QueryTriggerInteraction triggerMode = QueryTriggerInteraction.Ignore;
12
13 [Header("Debug")]
14 [SerializeField] private bool drawRay = true;
15
16 private void Reset()
17 {
18 targetCamera = Camera.main;
19 maxDistance = 100f;
20 pickMask = ~0;
21 triggerMode = QueryTriggerInteraction.Ignore;
22 drawRay = true;
23 }
24
25 private void Update()
26 {
27 if (targetCamera == null) return;
28
29 Ray ray = targetCamera.ScreenPointToRay(Input.mousePosition);
30 if (drawRay) Debug.DrawRay(ray.origin, ray.direction * maxDistance, Color.yellow);
31
32 if (!Input.GetMouseButtonDown(0)) return;
33
34 bool hit = Physics.Raycast(ray, out RaycastHit info, maxDistance, pickMask, triggerMode);
35 if (!hit)
36 {
37 Debug.Log("Click: miss");
38 return;
39 }
40
41 Debug.Log($"Click: {info.collider.name} layer={LayerMask.LayerToName(info.collider.gameObject.layer)} point={info.point}");
42 }
43}이 코드를 실행하면 콘솔에 "Click: TriggerBubble" 또는 "Click: TargetCube"가 찍힌다. triggerMode를 Collide로 바꾸면 트리거가 클릭을 가로채는 것이 더 자주 보인다. pickMask에서 특정 레이어를 빼면 물리 쿼리 후보가 줄어들고, 클릭 결과가 바뀐다. 클릭 판별이 ‘입력’ 문제가 아니라 ‘물리 쿼리 조건’ 문제인 경우가 많다는 사실이 로그로 확인된다.
1using UnityEngine;
2
3public class LayerMaskSetupHelper : MonoBehaviour
4{
5 [SerializeField] private string triggerLayerName = "Trigger";
6
7 private void Start()
8 {
9 int layer = LayerMask.NameToLayer(triggerLayerName);
10 Debug.Log($"Layer '{triggerLayerName}' index={layer} (if -1, create it in Project Settings > Tags and Layers)");
11
12 GameObject bubble = GameObject.Find("TriggerBubble");
13 if (bubble != null && layer >= 0)
14 {
15 bubble.layer = layer;
16 Debug.Log("Assigned TriggerBubble.layer");
17 }
18 }
19}Tags/Layer를 아직 안 만든 상태에서 NameToLayer는 -1을 반환한다. Console에 -1이 찍히면 Edit → Project Settings → Tags and Layers → Layers에서 User Layer 8 정도에 Trigger를 입력한다. 그 다음 Play를 다시 누르면 TriggerBubble이 해당 레이어로 바뀐다. 이 과정을 거치면 Pick Mask에서 특정 레이어를 제외하는 실험이 안정적으로 된다.
심화 활용
패턴 1: 클릭 대상 컴포넌트만 빠르게 찾기(캐싱 + TryGetComponent)
실무에서는 Raycast로 맞은 Collider의 GameObject가 ‘클릭 가능한 것’인지 판별하는 로직이 뒤따른다. 초보는 hit.collider.GetComponent<Clickable>()를 매번 호출한다. GetComponent는 네이티브 컴포넌트 배열을 순회하는 경로라서, 클릭이 잦거나 드래그로 매 프레임 Raycast를 하면 비용이 튄다. 클릭 가능한 대상이 정해져 있다면, Collider 인스턴스ID를 키로 캐싱하면 네이티브 왕복과 배열 순회를 줄인다.
1using System.Collections.Generic;
2using UnityEngine;
3
4public class ClickableCachePicker : MonoBehaviour
5{
6 [SerializeField] private Camera targetCamera;
7 [SerializeField] private LayerMask pickMask = ~0;
8
9 private readonly Dictionary<int, Clickable> _cache = new Dictionary<int, Clickable>(256);
10
11 private void Reset()
12 {
13 targetCamera = Camera.main;
14 }
15
16 private void Update()
17 {
18 if (targetCamera == null) return;
19 if (!Input.GetMouseButtonDown(0)) return;
20
21 Ray ray = targetCamera.ScreenPointToRay(Input.mousePosition);
22 if (!Physics.Raycast(ray, out RaycastHit hit, 500f, pickMask, QueryTriggerInteraction.Ignore)) return;
23
24 int id = hit.collider.GetInstanceID();
25 if (!_cache.TryGetValue(id, out Clickable clickable) || clickable == null)
26 {
27 if (!hit.collider.TryGetComponent(out clickable))
28 {
29 Debug.Log($"Hit {hit.collider.name} but no Clickable");
30 return;
31 }
32 _cache[id] = clickable;
33 }
34
35 clickable.OnClicked(hit.point);
36 }
37}
38
39public class Clickable : MonoBehaviour
40{
41 public void OnClicked(Vector3 point)
42 {
43 Debug.Log($"Clickable '{name}' clicked at {point}");
44 }
45}이 코드를 쓰면 첫 클릭에서만 TryGetComponent가 돌고, 이후에는 Dictionary 조회로 끝난다. Profiler에서 Scripts 쪽 비용이 줄어드는 것을 확인할 수 있다. 처음에 나도 GetComponent가 왜 느린지 감이 없었는데, Deep Profile로 보면 GetComponent 호출이 네이티브 바인딩을 타고 컴포넌트 리스트를 도는 시간이 보인다. 클릭이 아니라 드래그 하이라이트(매 프레임)로 가면 차이가 더 크게 난다.
패턴 2: 드래그/하이라이트는 NonAlloc + 레이 캐스트 빈도 제한
마우스가 움직일 때마다(또는 터치가 움직일 때마다) Raycast를 쏘면 60fps 기준 프레임당 1회는 괜찮아도, UI/카메라/오브젝트가 늘면 물리 쿼리 비용이 누적된다. RaycastAll은 결과 배열을 새로 만들거나 내부에서 할당이 생기기 쉬워 GC 문제로 이어진다. Physics.RaycastNonAlloc은 호출자가 미리 만든 배열을 재사용한다. 그리고 마우스가 같은 픽셀에 머물면 Raycast를 생략하는 식으로 빈도를 제한한다.
1using UnityEngine;
2
3public class HoverHighlighterNonAlloc : MonoBehaviour
4{
5 [SerializeField] private Camera targetCamera;
6 [SerializeField] private LayerMask pickMask = ~0;
7 [SerializeField] private float maxDistance = 200f;
8
9 private readonly RaycastHit[] _hits = new RaycastHit[8];
10 private Vector3 _lastMousePos;
11 private Transform _current;
12
13 private void Reset()
14 {
15 targetCamera = Camera.main;
16 }
17
18 private void Update()
19 {
20 if (targetCamera == null) return;
21
22 Vector3 mp = Input.mousePosition;
23 if (mp == _lastMousePos) return;
24 _lastMousePos = mp;
25
26 Ray ray = targetCamera.ScreenPointToRay(mp);
27 int count = Physics.RaycastNonAlloc(ray, _hits, maxDistance, pickMask, QueryTriggerInteraction.Ignore);
28
29 Transform next = count > 0 ? _hits[0].transform : null;
30 if (next == _current) return;
31
32 if (_current != null) Debug.Log($"Hover exit: {_current.name}");
33 _current = next;
34 if (_current != null) Debug.Log($"Hover enter: {_current.name}");
35 }
36}마우스를 움직일 때만 Raycast가 나가고, 결과 배열은 재사용된다. count가 8을 넘어갈 수 있는 상황이면 배열 크기를 늘리거나, ‘가장 가까운 것 1개만 필요’한 요구로 바꾸는 편이 설계가 깔끔하다. NonAlloc을 쓴다고 물리 쿼리 비용이 0이 되는 건 아니고, GC 스파이크를 없애는 데 초점이 있다.
내 흑역사 하나는 RaycastAll을 UI 드래그에 붙여 놓고, 빌드에서만 0.5초마다 프레임이 멈추던 사건이다. Profiler에서 GC.Alloc이 프레임당 수 KB씩 쌓이고, 어느 순간 GC.Collect가 터져 20~40ms 스파이크가 생겼다. 원인은 RaycastAll이 반환하는 배열과, 로그 문자열 생성이 합쳐진 것이었다.
해결은 두 가지였다. 첫째, 하이라이트는 Physics.Raycast(단일)로 바꾸고 레이어를 좁혔다. 둘째, 로그를 조건부로 줄이고, 에디터에서만 출력되게 컴파일 조건을 걸었다. 그 뒤로는 같은 기능이 모바일에서도 60fps를 유지했다. ‘클릭 판별’은 작아 보여도, 입력이 매 프레임 도는 순간 시스템 전체를 흔든다.
한 문단 요약: 화면 좌표는 Camera에서 Ray로 변환되고, Physics 쿼리는 네이티브 물리 월드에서 Collider 후보를 레이어/트리거 조건으로 걸러 가장 가까운 히트를 반환한다. 성능은 Raycast 자체보다 쿼리 빈도, 마스크 범위, GetComponent/문자열 생성 같은 주변 코드에서 무너진다.
자주 하는 실수
1) Collider가 없는데 클릭이 될 거라 믿음
증상: Game 뷰에서 오브젝트가 보이는데 클릭하면 항상 "Hit: none"이 찍힌다. 또는 어떤 오브젝트는 되고 어떤 오브젝트는 안 된다.
원인: MeshRenderer는 렌더링용이고, Raycast는 물리 월드의 Collider만 대상으로 한다. 임포트한 모델(예: FBX)은 Collider가 자동으로 붙지 않는 경우가 많다.
해결: 대상 오브젝트 선택 → Inspector → Add Component → Box Collider/Mesh Collider를 추가한다. Mesh Collider는 Convex/비용 이슈가 있으니 단순 형태면 Box/Sphere/Capsule이 우선이다.
2) UI 위를 클릭했는데 3D 오브젝트도 같이 눌림
증상: 버튼을 클릭해도 뒤의 월드 오브젝트가 선택된다. 특히 모바일에서 터치가 UI를 뚫는 느낌이 든다.
원인: EventSystem(UI 레이캐스트)와 Physics Raycast를 동시에 처리했기 때문이다. UI는 GraphicRaycaster, 3D는 PhysicsRaycaster/Physics.Raycast로 서로 다른 시스템이다.
해결: 클릭 처리 전에 EventSystem.current.IsPointerOverGameObject()로 UI 위인지 체크하고, UI 위면 월드 Raycast를 스킵한다. 새 Input System을 쓰면 PointerId 처리도 함께 맞춘다.
3) ScreenPointToRay에 잘못된 좌표계를 넣음
증상: 카메라를 회전/줌하면 클릭 위치가 점점 어긋난다. Scene 뷰에서는 맞는 것 같은데 Game 뷰에서는 빗나간다.
원인: Input.mousePosition은 Game 뷰 픽셀 좌표인데, 캔버스 로컬 좌표나 정규화 좌표(0~1)를 넣었다. 또는 targetCamera가 실제 렌더 카메라가 아니라 다른 카메라다.
해결: Ray를 Debug.DrawRay로 시각화해 방향을 먼저 확인한다. ScreenPointToRay에는 픽셀 좌표를 넣고, 카메라는 Inspector에서 명시적으로 연결한다(Camera.main 의존을 줄인다).
4) 레이어 마스크를 잘못 설정해 전부 미스 또는 전부 히트
증상: 특정 오브젝트만 절대 안 맞거나, 반대로 투명한 방해물/트리거가 계속 먼저 맞는다. 콘솔에는 엉뚱한 이름이 찍힌다.
원인: LayerMask 값은 비트마스크라서 숫자를 직접 넣으면 실수하기 쉽다. 또한 Trigger 전용 레이어를 만들지 않으면 필터링이 어렵다.
해결: Edit → Project Settings → Tags and Layers에서 레이어를 명확히 나누고, Inspector의 LayerMask 드롭다운으로 선택한다. 트리거/이펙트/데코는 클릭 마스크에서 제외한다.
5) FixedUpdate에서 클릭을 처리해 입력이 씹힘
증상: 클릭이 가끔 안 먹는다. 연타하면 어떤 클릭은 무시된다. 프레임레이트가 낮을수록 더 심해진다.
원인: FixedUpdate는 물리 틱이고, Input의 ‘Down’ 상태는 프레임 단위로 갱신된다. 물리 틱이 프레임과 1:1이 아니라서 Down 이벤트를 놓친다.
해결: 클릭/터치 시작 판정은 Update에서 처리하고, 물리 조작이 필요하면 결과를 저장해 FixedUpdate에서 소비한다(예: 클릭한 타겟을 변수에 담아 힘을 가함).
1using UnityEngine;
2
3public class ClickThenPhysics : MonoBehaviour
4{
5 [SerializeField] private Camera targetCamera;
6 [SerializeField] private LayerMask pickMask = ~0;
7
8 private Rigidbody _pending;
9
10 private void Reset()
11 {
12 targetCamera = Camera.main;
13 }
14
15 private void Update()
16 {
17 if (targetCamera == null) return;
18 if (!Input.GetMouseButtonDown(0)) return;
19
20 Ray ray = targetCamera.ScreenPointToRay(Input.mousePosition);
21 if (Physics.Raycast(ray, out RaycastHit hit, 200f, pickMask))
22 {
23 hit.collider.TryGetComponent(out _pending);
24 }
25 }
26
27 private void FixedUpdate()
28 {
29 if (_pending == null) return;
30 _pending.AddForce(Vector3.up * 5f, ForceMode.Impulse);
31 _pending = null;
32 }
33}Update는 입력 스냅샷을 안정적으로 읽고, FixedUpdate는 물리 엔진 스텝에 맞춰 힘을 적용한다. 이 분리가 Update/FixedUpdate가 존재하는 이유와 정확히 맞물린다.
성능 최적화 체크리스트
- Raycast 호출 빈도를 먼저 고정한다: 클릭은 GetMouseButtonDown, 하이라이트는 마우스 이동 시에만 실행
- LayerMask를 Everything으로 두지 않는다: 클릭 대상 레이어만 포함(예: Pickable)
- QueryTriggerInteraction을 명시한다: 트리거가 클릭을 가로채면 디버깅이 지옥이 된다
- Collider 없는 렌더 오브젝트를 점검한다: 모델 임포트 후 Collider 누락이 흔하다
- Camera.main 의존을 줄인다: 태그 검색은 느리고, 멀티 카메라에서 오동작한다
- GetComponent를 Update/드래그 루프에서 반복 호출하지 않는다: 캐싱 또는 TryGetComponent+Dictionary 사용
- RaycastAll 대신 단일 Raycast 또는 NonAlloc을 우선한다: 결과가 1개면 1개만 받는다
- Debug.Log 문자열 보간을 릴리즈 빌드에서 줄인다: 로그가 GC와 CPU를 같이 먹는다
- Profiler에서 Physics.Raycast 비용을 확인한다: Physics 섹션과 Scripts 섹션을 분리해서 본다
- 씬이 커지면 클릭용 프록시 콜라이더를 둔다: MeshCollider 남발 대신 단순 콜라이더로 대체
- 카메라가 여러 대면 어떤 카메라로 Ray를 만들지 명확히 한다: UI 카메라/월드 카메라 분리
- 2D/3D 물리 혼용을 피한다: Physics2D.Raycast와 Physics.Raycast는 서로 다른 월드다
자주 묻는 질문
Q1. ScreenPointToRay는 왜 Camera에 있고, Raycast는 왜 Physics에 있나?
ScreenPointToRay는 ‘화면 좌표를 월드 방향으로 바꾸는 일’이라서 카메라의 투영/뷰 행렬이 필요하다. 이 정보는 렌더링 시스템(카메라) 소유다. 반면 Raycast는 ‘월드에서 충돌 후보를 찾는 일’이라서 물리 월드(콜라이더, 브로드페이즈 구조, 트리거 규칙)가 필요하다. Unity는 카메라와 물리를 분리해 두 시스템이 독립적으로 진화하게 만들었다. 같은 Ray를 Physics가 아니라 NavMesh, 커스텀 공간 쿼리, 혹은 별도 PhysicsScene에도 던질 수 있게 된다. 다음 학습 키워드는 Camera projection matrix, PhysicsScene, 브로드페이즈/내로페이즈다.
Q2. Raycast가 느린지, 내 코드가 느린지 어떻게 가르나?
Profiler에서 CPU Usage를 열고 Physics 섹션과 Scripts 섹션을 분리해서 본다. Physics.Raycast 자체 비용은 Physics 카테고리에 잡히는 경우가 많고, 그 이후에 hit.collider.GetComponent, Debug.Log 문자열 생성, LINQ 같은 주변 코드 비용은 Scripts에 잡힌다. Deep Profile을 켜면 GetComponent 경로가 눈에 띄지만, 오버헤드가 커서 상황 재현용으로만 쓴다. 클릭은 프레임당 1회라 티가 안 나도, 드래그/호버는 프레임당 60회가 되어 비용이 바로 보인다. 다음 학습 키워드는 Profiler Timeline, GC Alloc 트래킹, RaycastNonAlloc이다.
Q3. RaycastHit는 out으로 받는데 GC Alloc이 생기나?
RaycastHit는 구조체라서 out으로 받아도 별도 managed 객체가 만들어지지 않는다. 그래서 RaycastHit 자체가 GC Alloc을 만들 가능성은 낮다. GC가 터지는 지점은 보통 다른 곳이다. 예를 들어 Debug.Log($"{hit.collider.name}")는 문자열을 만들고, hit.collider를 통해 UnityEngine.Object를 접근하면서 내부 마샬링이 일어날 수 있다. RaycastAll 결과를 매번 배열로 받거나, 리스트를 새로 만들면 그쪽에서 할당이 생긴다. 다음 학습 키워드는 struct vs class, 문자열 보간 할당, RaycastAll 반환 배열이다.
Q4. UI를 클릭했는데 월드 오브젝트가 같이 선택되는 문제는 어디서 막나?
UI는 EventSystem 기반 레이캐스터(GraphicRaycaster)가 처리하고, 월드는 Physics.Raycast가 처리한다. 둘은 같은 ‘클릭’이라도 다른 파이프라인이라서, 둘 다 실행하면 겹친다. 보통 Update에서 월드 Raycast를 하기 전에 EventSystem.current.IsPointerOverGameObject()로 UI 위인지 검사하고, UI 위면 월드 처리를 건너뛴다. 모바일은 포인터 아이디가 달라질 수 있어 새 Input System에서는 PointerId를 정확히 넘겨야 한다. 더 강하게 막으려면 UI가 열렸을 때 월드 입력 자체를 disable하는 상태 머신을 둔다. 다음 학습 키워드는 EventSystem, GraphicRaycaster, Input System pointer id다.
Q5. TriggerBubble 같은 트리거가 클릭을 가로채는 이유가 뭔가?
트리거 콜라이더도 물리 월드의 ‘형태’로 등록돼 있어서, 쿼리 관점에서는 일반 콜라이더와 비슷하게 후보가 된다. 차이는 충돌 해결(밀어내기)을 하느냐, 이벤트(OnTriggerEnter)를 내느냐인데, Raycast는 ‘교차’만 보므로 트리거도 맞을 수 있다. Project Settings → Physics의 Queries Hit Triggers 설정과, Raycast 오버로드의 QueryTriggerInteraction 값이 함께 작동해 결과가 달라진다. 클릭 판별은 보통 트리거를 Ignore로 두고, 클릭용 레이어를 따로 만들어 후보를 줄인다. 다음 학습 키워드는 QueryTriggerInteraction, Physics settings, 레이어 설계다.
Q6. Camera.main을 쓰면 왜 문제가 생기나?
Camera.main은 내부적으로 MainCamera 태그를 가진 카메라를 찾는다. 이 과정은 캐시가 있더라도 씬 로딩/카메라 교체/비활성화 상황에서 다시 탐색이 일어나거나, 멀티 카메라 구성에서 의도치 않은 카메라가 잡힐 수 있다. 특히 UI 카메라와 월드 카메라가 분리된 프로젝트에서 main 카메라가 UI 카메라로 바뀌면 Ray 방향이 완전히 달라져 클릭이 전부 빗나간다. Inspector에서 명시적으로 카메라를 연결하면 이런 런타임 변동을 제거한다. 다음 학습 키워드는 태그 검색 비용, 멀티 카메라 렌더링, 카메라 스태킹이다.
Q7. 2D 게임인데 Physics.Raycast를 써도 되나?
2D와 3D는 물리 월드가 다르다. 2D는 Physics2D.Raycast를 써야 Collider2D/Rigidbody2D를 대상으로 쿼리한다. 3D Physics.Raycast는 Collider(3D)만 본다. 스프라이트가 보이는데 클릭이 안 되는 전형적인 이유가 ‘Collider2D는 있는데 3D Raycast를 쐈다’는 실수다. 반대로 3D 오브젝트에 2D 레이캐스트를 쏘면 항상 미스다. 프로젝트가 혼합(2.5D)이라면 클릭 대상 월드를 기준으로 API를 분리하고, 카메라/레이어도 분리한다. 다음 학습 키워드는 Physics2D, Collider2D vs Collider, 혼합 물리 설계다.