6. Unity Raycast로 마우스 클릭 오브젝트 선택하기: ScreenPointToRay 내부까지
Unity Camera.ScreenPointToRay와 Physics.Raycast로 마우스 클릭 오브젝트 선택을 구현한다. C#→네이티브 바인딩, PlayerLoop 타이밍, GC/성능 함정까지 연결한다. 3D/2D 차이와 레이어마스크, UI 차단도 포함한다.
Unity Raycast로 마우스 클릭 오브젝트 선택하기: ScreenPointToRay 내부까지
게임 만들다 겪는 사고 중 하나가 “클릭했는데 엉뚱한 오브젝트가 선택된다”이다. 씬 뷰에서는 맞는 것 같은데 Game 뷰에서는 뒤에 있는 오브젝트가 잡히거나, UI 버튼 위를 눌렀는데 월드 오브젝트가 선택되기도 한다. 원인은 대개 좌표계 변환, 물리 업데이트 타이밍, 레이어/콜라이더 설정, 그리고 C# 호출이 네이티브 물리 월드로 넘어가는 경로를 모른 채 코드를 붙이는 데 있다. 클릭 선택은 짧은 코드로 끝나지만, 내부 파이프라인을 모르면 디버깅이 길어진다. 처음에 나도 “ScreenPointToRay가 왜 카메라마다 다르게 느껴지지?”로 3시간을 태웠다. 원근 카메라에서만 어긋나는 줄 알았는데, 실제로는 카메라의 viewport rect, Game 뷰 해상도 스케일, 그리고 UI가 입력을 먼저 먹는 순서
핵심 개념
마우스 클릭으로 오브젝트를 선택한다는 말은 “화면의 2D 좌표를 3D 세계의 광선으로 바꾸고, 그 광선이 물리 월드의 콜라이더와 교차하는지 검사한다”라는 뜻이다. Unity에서 그 변환을 담당하는 API가 Camera.ScreenPointToRay이다. 이 API가 필요한 이유는, 화면 좌표는 픽셀 단위이고 월드 좌표는 카메라의 투영 행렬과 뷰 행렬을 거쳐야만 연결되기 때문이다.
핵심 용어를 딱 5개로 잡으면 디버깅이 빨라진다. Ray는 원점(origin)과 방향(direction)으로 정의되는 반직선이다. RaycastHit은 충돌 지점, 노멀, 거리, Collider 참조를 담는 결과 구조체다(값 타입이라 C# 힙 할당이 없다). Collider는 물리 월드에 등록된 형태 정보이며, MeshRenderer와는 무관하다. LayerMask는 물리 쿼리에서 후보를 줄이는 비트마스크다. 마지막으로 PhysicsScene은 현재 씬의 물리 월드 핸들로, Raycast는 결국 이 네이티브 월드에 질의를 던진다.
왜 ScreenPointToRay가 “카메라 메서드”로 설계됐는지도 이유가 있다. 같은 화면 좌표라도 카메라가 다르면 광선이 달라진다. FOV, orthographic 여부, near/far, viewport rect, 카메라의 트랜스폼이 모두 투영에 관여한다. 입력 좌표 하나를 월드로 풀어내려면 카메라의 모든 상태가 필요하니 Camera가 책임을 갖는 형태가 자연스럽다.
Raycast 선택은 Update에서 처리하는 편이 보통 안정적이다. 입력(Input.GetMouseButtonDown)은 프레임 기반 이벤트이고, FixedUpdate는 고정 시간 물리 스텝이라 입력과 엇갈리기 쉽다. 물리 자체는 내부적으로 고정 스텝으로 시뮬레이션되지만, “Raycast 질의”는 현재 물리 월드 스냅샷에 대한 쿼리라 Update에서 실행해도 된다. 다만 물리 스텝 직후/직전 차이로 한 프레임 어긋나는 느낌이 날 수 있어, 그때는 타이밍을 의식해야 한다.
1using UnityEngine;
2
3public class ClickSelectBasic : 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 (targetCamera == null) return;
16 if (!Input.GetMouseButtonDown(0)) return;
17
18 Ray ray = targetCamera.ScreenPointToRay(Input.mousePosition);
19 if (Physics.Raycast(ray, out RaycastHit hit, 200f, pickMask, QueryTriggerInteraction.Ignore))
20 {
21 Debug.Log($"Picked: {hit.collider.name} at {hit.point} dist={hit.distance:F2}");
22 }
23 else
24 {
25 Debug.Log("Picked: none");
26 }
27 }
28}이 코드를 실행하면, Game 뷰에서 큐브를 클릭할 때 콘솔에 “Picked: Cube …”가 찍힌다. 반대로 콜라이더가 없는 오브젝트는 아무리 클릭해도 none이 찍힌다. 렌더링은 보이는데 선택이 안 되는 상황의 80%가 여기서 시작한다. 선택은 ‘렌더러’가 아니라 ‘콜라이더’를 맞히는 물리 쿼리이기 때문이다.
엔진 관점에서의 내부 동작
C#에서 Camera.ScreenPointToRay를 호출하면, 관리 코드가 카메라의 뷰/프로젝션 행렬을 조회하고(엔진 내부에서는 카메라 컴포넌트의 네이티브 상태), 스크린 픽셀 좌표를 정규화된 뷰포트 좌표로 바꾼 뒤, 클립 공간→뷰 공간→월드 공간 역변환을 수행해 Ray를 만든다. Ray 자체는 C# 구조체라 스택에 잡히고, GC Alloc은 없다. 문제는 ‘행렬을 가져오는 과정’과 ‘물리 쿼리 바인딩’이 네이티브 호출을 동반한다는 점이다.
Physics.Raycast는 C# 래퍼가 네이티브 물리 엔진(플랫폼에 따라 PhysX 계열)을 호출하는 형태다. C#에서 넘긴 Ray(원점/방향), maxDistance, layerMask, QueryTriggerInteraction 같은 값이 마샬링되어 C++ 쪽으로 전달되고, 네이티브 물리 월드의 broadphase(대략적인 후보 찾기) → narrowphase(정확한 교차 계산) 순으로 검사한다. RaycastHit은 결과를 다시 C# 구조체로 채워준다. 이때도 힙 할당은 보통 0이지만, hit.collider 접근 이후 GetComponent를 연쇄 호출하면 그때 비용이 튄다.
PlayerLoop에서 입력과 스크립트 Update는 물리 시뮬레이션과 분리되어 있다. 대략적인 흐름은 ‘Input 업데이트 → Update 호출 → LateUpdate → 렌더링’이며, 물리 시뮬레이션은 고정 스텝으로 별도 구간에서 돌아간다. Raycast는 “시뮬레이션을 진행”하는 작업이 아니라 “현재 물리 월드에 질의”하는 작업이라 Update에서 호출해도 된다. 다만 FixedUpdate 직후에 콜라이더 위치가 갱신되는 프레임과 그렇지 않은 프레임이 섞이면, 빠르게 움직이는 물체를 클릭할 때 한 프레임 전 위치를 맞히는 느낌이 날 수 있다.
내가 겪었던 대표 삽질은 ‘애니메이션으로 움직이는 캐릭터를 클릭하면 가끔 빗나감’이었다. 콘솔에는 hit.point가 캐릭터 근처에 찍히는데, 시각적으로는 손가락이 몸통을 눌렀다. 원인은 Animator가 Transform을 Update 이후에 적용하는 설정(또는 LateUpdate 계열)과 물리 콜라이더가 어떤 타이밍에 동기화되는지 차이였다. 해결은 캐릭터를 Rigidbody/Collider 기반으로 움직이게 하거나, 클릭 판정 타이밍을 LateUpdate로 옮겨 프레임 내 변환 순서를 맞추는 방식이었다.
레이어마스크가 성능과 디버깅 모두에 직결되는 이유도 엔진 내부를 보면 명확하다. broadphase는 공간 분할 구조에서 후보를 모으는데, 레이어마스크가 있으면 애초에 후보군에서 제외한다. 마스크를 안 쓰면 씬에 콜라이더가 많을수록 후보가 늘고, narrowphase까지 내려가는 수가 증가한다. 실제 프로젝트에서 5천 개 콜라이더가 있는 씬에서 마스크 없이 클릭 Raycast를 매 프레임 돌렸더니, 특정 모바일 기기에서 Physics.Raycast가 0.3~0.8ms까지 튄 적이 있다.
메모리 관점에서 RaycastHit은 값 타입이라 안전해 보이지만, hit.collider.gameObject.name 같은 문자열 접근이 디버그 로그에서 GC를 일으킬 수 있다. 특히 문자열 보간($"...")은 내부적으로 문자열을 만들고, Editor에서는 로그가 더 무겁다. 런타임 성능을 보려면 Development Build + Autoconnect Profiler 환경에서 확인하는 편이 정확하다.
1using UnityEngine;
2
3public class PlayerLoopTrace : 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 frame={Time.frameCount} fixedTime={Time.fixedTime:F3}");
25 }
26
27 private void Update()
28 {
29 if (_frame < 5) Debug.Log($"Update frame={Time.frameCount} time={Time.time:F3}");
30 _frame++;
31 }
32
33 private void LateUpdate()
34 {
35 if (Time.frameCount < 5) Debug.Log($"LateUpdate frame={Time.frameCount}");
36 }
37}이 스크립트를 빈 오브젝트에 붙이고 Play를 누르면, 콘솔에 Awake/OnEnable/Start가 먼저 찍힌 뒤 Update와 FixedUpdate가 서로 다른 리듬으로 찍힌다. 클릭 선택을 FixedUpdate에 넣었을 때 ‘클릭이 씹히는’ 느낌이 생기는 이유가 여기서 드러난다. 입력은 프레임 단위로 갱신되는데 FixedUpdate는 프레임당 0~여러 번 호출될 수 있다.
1using UnityEngine;
2
3public class RaycastAllocProbe : 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 (targetCamera == null) return;
16 if (!Input.GetMouseButton(0)) return;
17
18 // Profiler에서 GC Alloc 컬럼을 보려면 문자열 로그를 잠시 끈다.
19 Ray ray = targetCamera.ScreenPointToRay(Input.mousePosition);
20 bool hitAny = Physics.Raycast(ray, out RaycastHit hit, 500f, pickMask);
21
22 if (hitAny)
23 {
24 // 여기서 GetComponent를 매 프레임 호출하면 비용이 누적된다.
25 var rb = hit.collider.GetComponent<Rigidbody>();
26 if (rb != null) rb.WakeUp();
27 }
28 }
29}이 코드는 ‘클릭을 누르고 있는 동안’ 매 프레임 Raycast와 GetComponent를 호출한다. Profiler에서 Scripts와 Physics 구간을 같이 보면, Raycast보다 GetComponent 쪽이 더 눈에 띄는 경우가 많다. GetComponent는 네이티브에 있는 컴포넌트 리스트를 찾아 래퍼를 돌려주는 작업이라 반복 호출 시 비용이 쌓인다. 클릭 한 번이면 문제가 안 되지만, 드래그 선택이나 하이라이트처럼 매 프레임 호출하는 기능으로 확장되면 병목이 된다.
실습하기
1단계: 프로젝트 생성과 입력/카메라 전제 맞추기
Unity Hub → New project → 3D (Built-in Render Pipeline) 템플릿을 선택한다. 버전은 2022.3 LTS 계열이 무난하다. 프로젝트가 열리면 Game 뷰 상단의 Aspect가 Free Aspect인지, 해상도 스케일이 특이하게 잡혀 있지 않은지 먼저 확인한다. ScreenPointToRay는 픽셀 좌표를 사용하니 Game 뷰 해상도 설정이 디버깅에 영향을 준다.
Hierarchy에서 Main Camera를 클릭하고 Inspector에서 Tag가 MainCamera인지 확인한다. Scene 뷰에서 카메라가 원점 근처를 보고 있는지도 확인한다(Transform Position (0,1,-10) 정도). 카메라가 여러 대일 때 Camera.main은 첫 번째 MainCamera 태그 카메라를 찾는데, 이 검색도 내부적으로 Find 작업이라 프레임마다 호출하면 비용이 생긴다. 실습에서는 Reset에서 한 번 잡아둔다.
2단계: 씬 구성(콜라이더/레이어/테스트 표적 만들기)
Hierarchy 우클릭 → 3D Object → Cube를 만든다. Inspector에서 Cube에 Box Collider가 자동으로 붙어 있는지 확인한다. 이어서 Hierarchy 우클릭 → 3D Object → Sphere를 만들고, Position을 (2,0,0) 정도로 옮긴다. 둘 다 콜라이더가 있어야 Raycast가 맞는다. Renderer만 있고 Collider가 없으면 클릭 선택이 절대 되지 않는다.
레이어 필터링을 체감하려면 레이어를 나눠둔다. 상단 메뉴 Edit → Project Settings → Tags and Layers에서 User Layer 8을 Pickable로 만든다. Cube와 Sphere를 각각 선택하고 Inspector 상단 Layer 드롭다운에서 Pickable로 지정한다. 이때 ‘This will change the layer of all child objects’ 팝업이 뜨면 Yes를 누른다(자식이 없는 오브젝트라 영향은 없다).
3단계: 클릭 선택 스크립트 붙이고 Play에서 눈으로 확인하기
Hierarchy 우클릭 → Create Empty로 빈 오브젝트를 만들고 이름을 ClickSystem으로 바꾼다. Project 창에서 C# Script를 만들고 이름을 ClickSelectBasic으로 한 뒤, ClickSystem에 드래그해서 붙인다. Inspector에서 Target Camera 슬롯이 비어 있으면 Main Camera를 드래그해서 넣는다. Pick Mask는 Pickable만 선택되도록 설정한다(드롭다운에서 Pickable 체크).
Play를 누르고 Game 뷰에서 Cube를 클릭하면 콘솔에 Picked: Cube가 찍힌다. 빈 공간을 클릭하면 Picked: none이 찍힌다. Sphere를 클릭하면 Picked: Sphere가 찍힌다. 만약 아무것도 안 찍히면, (1) 카메라가 오브젝트를 보고 있는지 (2) 오브젝트에 Collider가 있는지 (3) Pick Mask가 Pickable을 포함하는지부터 확인한다.
1using UnityEngine;
2using UnityEngine.EventSystems;
3
4public class ClickSelectWithUiBlock : MonoBehaviour
5{
6 [SerializeField] private Camera targetCamera;
7 [SerializeField] private LayerMask pickMask = ~0;
8 [SerializeField] private bool ignoreWhenPointerOverUI = true;
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 if (ignoreWhenPointerOverUI && EventSystem.current != null && EventSystem.current.IsPointerOverGameObject())
21 {
22 Debug.Log("UI consumed pointer; world pick skipped");
23 return;
24 }
25
26 Ray ray = targetCamera.ScreenPointToRay(Input.mousePosition);
27 if (Physics.Raycast(ray, out RaycastHit hit, 200f, pickMask))
28 {
29 Debug.Log($"Picked: {hit.collider.name}");
30 }
31 }
32}UI가 있는 프로젝트에서 ‘버튼을 눌렀는데 월드 오브젝트가 같이 선택되는’ 문제는 EventSystem 순서 때문이다. EventSystem은 입력을 받아 UI 레이캐스트를 먼저 수행하고, 월드 쪽 코드는 Update에서 그대로 실행된다. IsPointerOverGameObject로 UI 위 클릭을 필터링하면 콘솔에 “UI consumed…”가 찍히고 월드 선택이 멈춘다. UI가 없는 씬에서는 EventSystem.current가 null이라 조건이 자연스럽게 빠진다.
1using UnityEngine;
2
3public class ClickHighlight : MonoBehaviour
4{
5 [SerializeField] private Camera targetCamera;
6 [SerializeField] private LayerMask pickMask = ~0;
7 [SerializeField] private Color highlightColor = Color.yellow;
8
9 private Renderer _current;
10 private Color _original;
11
12 private void Reset()
13 {
14 targetCamera = Camera.main;
15 }
16
17 private void Update()
18 {
19 if (targetCamera == null) return;
20 if (!Input.GetMouseButtonDown(0)) return;
21
22 Ray ray = targetCamera.ScreenPointToRay(Input.mousePosition);
23 if (!Physics.Raycast(ray, out RaycastHit hit, 200f, pickMask)) return;
24
25 if (_current != null) _current.material.color = _original;
26
27 _current = hit.collider.GetComponent<Renderer>();
28 if (_current != null)
29 {
30 _original = _current.material.color;
31 _current.material.color = highlightColor;
32 }
33 }
34}이 코드를 붙이고 클릭하면 선택된 오브젝트 색이 바뀐다. 여기서 일부러 함정을 하나 밟는다. Renderer.material 접근은 머티리얼 인스턴스를 생성할 수 있어(특히 공유 머티리얼일 때) 메모리와 드로우콜에 영향을 준다. 클릭 선택이 하이라이트 기능으로 커질 때, ‘선택할 때마다 머티리얼이 늘어나는’ 현상이 생기기 쉽다. 색만 바꾸려면 MaterialPropertyBlock을 쓰는 편이 안전하다.
심화 활용
한 문단 요약: 클릭 선택은 ScreenPointToRay로 광선을 만들고 PhysicsScene에 Raycast 질의를 던지는 구조다. 성능은 레이어마스크와 호출 빈도에서 갈리고, 정확도는 PlayerLoop 타이밍(Transform/Animator/Physics 동기화)과 UI 입력 선점에서 갈린다.
패턴 1: RaycastNonAlloc으로 드래그/호버 선택 비용 고정하기
실무에서는 클릭 한 번보다 ‘호버 하이라이트’나 ‘드래그로 선택 박스’가 더 자주 나온다. 이때 Physics.Raycast를 매 프레임 호출해도 괜찮지만, 여러 개를 맞히는 RaycastAll을 쓰면 배열 할당이 튀기 쉽다. RaycastNonAlloc은 결과 버퍼를 재사용하니, 프레임마다 결과 배열이 새로 생성되는 상황을 막는다. 이 방식은 GC Alloc을 0으로 고정시키는 데 효과가 있다.
1using UnityEngine;
2
3public class HoverPickNonAlloc : MonoBehaviour
4{
5 [SerializeField] private Camera targetCamera;
6 [SerializeField] private LayerMask pickMask = ~0;
7 [SerializeField] private float maxDistance = 300f;
8
9 private readonly RaycastHit[] _hits = new RaycastHit[8];
10 private int _lastInstanceId;
11
12 private void Reset()
13 {
14 targetCamera = Camera.main;
15 }
16
17 private void Update()
18 {
19 if (targetCamera == null) return;
20
21 Ray ray = targetCamera.ScreenPointToRay(Input.mousePosition);
22 int count = Physics.RaycastNonAlloc(ray, _hits, maxDistance, pickMask, QueryTriggerInteraction.Ignore);
23
24 int pickedId = 0;
25 if (count > 0)
26 {
27 float best = float.MaxValue;
28 for (int i = 0; i < count; i++)
29 {
30 if (_hits[i].distance < best)
31 {
32 best = _hits[i].distance;
33 pickedId = _hits[i].collider.GetInstanceID();
34 }
35 }
36 }
37
38 if (pickedId != _lastInstanceId)
39 {
40 _lastInstanceId = pickedId;
41 Debug.Log(pickedId == 0 ? "Hover: none" : $"Hover: {pickedId}");
42 }
43 }
44}이 스크립트는 마우스를 움직일 때마다 호버 대상이 바뀌는 순간만 로그가 찍힌다. Profiler에서 GC Alloc이 0으로 유지되는지 확인할 수 있다. 배열 크기 8은 임의 값이라, 실제 게임에서는 씬 밀도에 맞춰 조정한다. 결과가 8개를 넘으면 잘리니, ‘가끔 선택이 누락되는’ 증상으로 나타날 수 있다.
패턴 2: GetComponent 비용을 클릭 파이프라인 밖으로 밀어내기
선택 후에 컴포넌트를 여러 개 읽는 UI 인스펙터(이름, 체력, 아이콘)를 붙이면, hit.collider에서 시작해 GetComponent 체인이 길어진다. 이 비용은 클릭 순간에는 티가 안 나는데, 드래그 선택이나 멀티 선택으로 확장되면 프레임 드랍으로 바뀐다. 해결은 ‘선택 가능한 오브젝트’에 전용 컴포넌트(예: SelectableTarget)를 붙이고, Awake에서 필요한 참조를 캐싱하는 방식이다.
1using UnityEngine;
2
3public class SelectableTarget : MonoBehaviour
4{
5 public Renderer CachedRenderer { get; private set; }
6 public Rigidbody CachedRigidbody { get; private set; }
7
8 private void Awake()
9 {
10 CachedRenderer = GetComponentInChildren<Renderer>();
11 CachedRigidbody = GetComponent<Rigidbody>();
12 }
13}Awake에서 캐싱하면, 클릭 순간에 네이티브 컴포넌트 리스트를 뒤지는 비용이 사라진다. 이게 왜 빨라지냐면, GetComponent는 C# 객체 그래프가 아니라 네이티브 쪽 컴포넌트 저장소를 조회하고, 필요하면 C# 래퍼 객체를 연결해야 하기 때문이다. 캐싱은 그 연결 작업을 한 번으로 줄인다.
1using UnityEngine;
2
3public class ClickSelectCached : MonoBehaviour
4{
5 [SerializeField] private Camera targetCamera;
6 [SerializeField] private LayerMask pickMask = ~0;
7
8 private SelectableTarget _current;
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)) return;
22
23 var next = hit.collider.GetComponentInParent<SelectableTarget>();
24 if (next == null)
25 {
26 Debug.Log($"Hit {hit.collider.name} but no SelectableTarget found");
27 return;
28 }
29
30 _current = next;
31 Debug.Log($"Selected: {_current.name} renderer={_current.CachedRenderer != null}");
32 }
33}콘솔에 ‘Hit X but no SelectableTarget found’가 찍히면, 콜라이더가 SelectableTarget의 자식에 있고 부모에 SelectableTarget을 안 붙인 상태라는 뜻이다. 이 로그는 디버깅 시간을 줄여준다. 예전에 이 케이스로 “Raycast는 맞는데 선택이 안 된다”를 반나절 잡은 적이 있다. 원인은 프리팹 구조가 바뀌면서 콜라이더가 더 깊은 자식으로 내려갔는데, 코드는 GetComponent만 써서 부모를 못 찾았다.
내 흑역사 하나 더 있다. 모바일에서 터치 선택을 붙이면서 Input.mousePosition을 그대로 썼더니, 특정 해상도에서만 클릭 위치가 살짝 밀렸다. 로그로 Screen.width/height와 Input.mousePosition을 찍어보니, Canvas Scaler가 적용된 UI 좌표와 혼동하고 있었다. 해결은 ‘월드 선택은 반드시 ScreenPointToRay에 스크린 픽셀 좌표를 넣는다’로 원칙을 고정하고, UI 좌표 변환은 별도 경로(RectTransformUtility)로 분리했다.
자주 하는 실수
1) Renderer만 있고 Collider가 없는데 클릭이 될 거라 착각함
증상: 화면에 분명히 보이는 오브젝트를 클릭해도 항상 Picked: none이 찍힌다. Scene 뷰에서 Gizmo를 켜도 콜라이더 윤곽선이 보이지 않는다.
원인: Physics.Raycast는 물리 월드의 Collider에만 교차 테스트를 한다. MeshRenderer, SpriteRenderer는 렌더링용이고 물리 월드 등록 대상이 아니다. 네이티브 broadphase 후보군에 들어가지도 않는다.
해결: 오브젝트 선택 후 Inspector → Add Component → Box Collider(또는 Mesh Collider)를 추가한다. Mesh Collider는 비용이 크니 정적 지형에만 쓰고, 클릭 선택 대상은 단순 콜라이더로 근사한다.
2) LayerMask를 0으로 두고 ‘Raycast가 고장났다’고 판단함
증상: Raycast가 절대 맞지 않는데, Debug.DrawRay로 광선을 그려보면 오브젝트 방향으로 제대로 향한다. 특히 인스펙터에서 pickMask를 건드린 뒤부터 발생한다.
원인: layerMask가 0이면 어떤 레이어도 포함하지 않는다는 뜻이다. 네이티브 쿼리 단계에서 후보가 전부 필터링되어 narrowphase로 내려가지 않는다. 코드가 정상이라도 결과는 항상 false다.
해결: 인스펙터에서 Pick Mask 드롭다운을 열고 필요한 레이어를 체크한다. 전체를 대상으로 하면 ~0을 사용한다. 레이어를 새로 만들었으면 오브젝트 Layer도 같이 바꿔야 한다.
3) UI 위 클릭이 월드 선택까지 같이 타고 들어옴
증상: 버튼을 클릭했는데 월드 오브젝트도 선택된다. 콘솔에 버튼 클릭 로그와 Picked 로그가 같은 프레임에 찍힌다.
원인: EventSystem은 UI 레이캐스트로 클릭 이벤트를 처리하지만, 사용자 스크립트 Update는 별도로 계속 돈다. 입력이 ‘소비(consumed)’되는 구조가 아니라, 둘 다 같은 입력 상태를 읽는 구조라서 동시에 반응한다.
해결: EventSystem.current.IsPointerOverGameObject()로 UI 위 입력을 필터링한다. 터치까지 고려하면 fingerId 버전도 필요하다. UI가 없는 씬에서는 EventSystem이 null일 수 있으니 null 체크를 둔다.
4) FixedUpdate에서 클릭을 처리해서 클릭이 씹힘
증상: 클릭이 가끔 먹고 가끔 안 먹는다. 특히 프레임이 떨어질 때 더 자주 발생한다. Debug.Log를 찍으면 FixedUpdate가 한 프레임에 여러 번 호출되거나 아예 호출되지 않는 프레임이 있다.
원인: Input.GetMouseButtonDown은 프레임 단위로 true가 1프레임만 유지된다. FixedUpdate는 고정 스텝이라 프레임과 1:1이 아니다. 입력이 true인 프레임에 FixedUpdate가 없으면 이벤트를 놓친다.
해결: 입력 이벤트는 Update에서 수집하고, 물리와 결합해야 하면 ‘플래그를 저장’해서 FixedUpdate에서 소비한다. 클릭 선택처럼 쿼리만 하는 경우는 Update에서 처리한다.
5) 카메라가 여러 개인데 Camera.main을 매 프레임 호출함
증상: 클릭 선택 자체는 되는데, 특정 씬에서만 스파이크가 생긴다. Profiler에서 Scripts 쪽에 Camera.main 호출이 눈에 띄고, CPU Usage가 들쭉날쭉하다.
원인: Camera.main은 내부적으로 MainCamera 태그 오브젝트를 찾는다. 이 검색은 캐시가 있어도 씬 상황에 따라 비용이 생길 수 있고, 무엇보다 매 프레임 호출할 이유가 없다. 카메라가 바뀌는 게임이라면 이벤트 기반으로 갱신해야 한다.
해결: SerializeField로 카메라를 명시적으로 참조하거나, Start/OnEnable에서 한 번만 캐싱한다. 카메라 전환이 있으면 전환 시점에만 targetCamera를 교체한다.
1using UnityEngine;
2
3public class InputToFixedBridge : MonoBehaviour
4{
5 private bool _clickDown;
6
7 private void Update()
8 {
9 if (Input.GetMouseButtonDown(0))
10 _clickDown = true;
11 }
12
13 private void FixedUpdate()
14 {
15 if (!_clickDown) return;
16 _clickDown = false;
17
18 // 여기서 물리 기반 동작(예: Rigidbody에 힘 적용)을 처리한다.
19 Debug.Log($"Consumed click in FixedUpdate at fixedTime={Time.fixedTime:F3}");
20 }
21}이 브리지 패턴은 ‘입력은 Update에서 수집, 물리는 FixedUpdate에서 처리’라는 분리를 강제한다. 클릭 선택은 보통 물리 힘을 주지 않으니 Update에서 끝내지만, 클릭으로 발사체를 쏘고 Rigidbody.AddForce를 건다면 이런 형태가 프레임 의존 버그를 줄인다.
성능 최적화 체크리스트
- Raycast 대상 오브젝트에 Collider가 실제로 붙어 있는지(Inspector에서 Box Collider 체크)
- pickMask가 0이 아닌지, 의도한 레이어(Pickable 등)를 포함하는지
- Raycast maxDistance가 카메라-대상 거리보다 큰지(원근 카메라에서 특히)
- QueryTriggerInteraction 설정이 의도와 맞는지(트리거만 있는 오브젝트는 Ignore면 절대 안 맞음)
- Update에서 Input.GetMouseButtonDown을 처리하고 FixedUpdate로 넘길 필요가 있는지 판단했는지
- Camera.main을 매 프레임 호출하지 않고, SerializeField/캐싱으로 참조를 고정했는지
- UI가 있는 씬에서 EventSystem.IsPointerOverGameObject로 월드 선택을 차단했는지
- 호버/드래그처럼 매 프레임 쿼리라면 RaycastNonAlloc 또는 버퍼 재사용으로 GC를 0으로 고정했는지
- Debug.Log 문자열 보간을 반복 호출 경로에서 제거했는지(Profiler에서 GC Alloc 확인)
- 선택 후 GetComponent 연쇄 호출을 줄이기 위해 SelectableTarget 같은 캐시 컴포넌트를 두었는지
- 복수 카메라/분할 화면이면 ScreenPointToRay에 쓰는 카메라가 ‘그 뷰’를 렌더링하는 카메라인지
- 투명 오브젝트/파티클을 클릭 대상으로 착각하지 않았는지(렌더링과 물리 히트는 별개)
자주 묻는 질문
ScreenPointToRay가 만드는 Ray의 origin은 어디인가? 카메라 위치인가, near plane인가?
Unity의 Ray는 ‘월드 공간에서의 원점과 방향’만 담는다. ScreenPointToRay는 카메라가 가진 투영(원근/직교)에 따라 방향을 만들고, 원점은 보통 카메라 위치를 기준으로 잡힌다. 다만 실제 교차 계산은 maxDistance와 near plane 관계, 그리고 내부 구현에서 near plane을 기준으로 시작점을 보정하는 형태가 섞여 보일 수 있다. 디버깅할 때는 Ray.origin을 콘솔에 찍고, Scene 뷰에서 Debug.DrawRay(ray.origin, ray.direction * 10f)로 직접 확인하는 편이 빠르다. 학습 키워드는 ‘view matrix’, ‘projection matrix’, ‘unproject’이다.
왜 Raycast는 Update에서 해도 물리 엔진과 충돌하지 않나? FixedUpdate가 아닌데 괜찮나?
Physics.Raycast는 ‘시뮬레이션을 한 스텝 진행’하는 API가 아니라 ‘현재 물리 월드 상태에 질의’하는 API다. 물리 월드는 고정 스텝으로 갱신되고, Raycast는 그 스냅샷을 읽는다. 그래서 Update에서 호출해도 엔진이 깨지지 않는다. 다만 빠르게 움직이는 물체나 Animator로 움직이는 물체는 프레임 내 변환 적용 순서 때문에 한 프레임 어긋나 보일 수 있다. 이 경우 LateUpdate에서 쿼리하거나, Rigidbody 기반 이동으로 물리 월드와 트랜스폼 갱신 타이밍을 맞춘다. 학습 키워드는 ‘PlayerLoop’, ‘Transform sync’, ‘Physics autoSyncTransforms’이다.
Physics.Raycast가 GC Alloc을 만들지 않는다는데, 왜 Profiler에서 GC가 튈 때가 있나?
Raycast 자체는 보통 힙 할당이 없다(Ray, RaycastHit이 구조체). GC가 튀는 흔한 지점은 주변 코드다. 예를 들어 Debug.Log에 문자열 보간을 쓰면 매 호출마다 문자열이 생성된다. hit.collider.name 접근도 Editor 환경에서는 추가 비용이 붙을 수 있다. 또 RaycastAll을 쓰면 결과 배열이 새로 만들어져 GC가 발생한다. Profiler에서 GC Alloc 컬럼을 켜고, Raycast 호출 전후로 어떤 코드가 같이 실행되는지 확인해야 한다. 학습 키워드는 ‘Profiler GC Alloc’, ‘RaycastNonAlloc’, ‘string allocation’이다.
GetComponent를 클릭 때마다 호출해도 되나? 왜 캐싱하면 빨라지나?
클릭 한 번만 처리한다면 GetComponent 비용은 체감이 거의 없다. 문제가 되는 순간은 ‘호버 하이라이트’처럼 매 프레임 호출하거나, 멀티 선택으로 한 프레임에 수십 번 호출할 때다. GetComponent는 C# 리스트에서 찾는 게 아니라, 네이티브 쪽 GameObject의 컴포넌트 저장소를 조회하고, 타입 매칭을 수행한 뒤 C# 래퍼를 연결한다. 이 과정이 반복되면 프레임당 0.01~0.1ms 단위로 누적될 수 있다(플랫폼/컴포넌트 수에 따라 변동). Awake에서 참조를 캐싱하면 이 경로가 한 번으로 줄어든다. 학습 키워드는 ‘native binding’, ‘component lookup’, ‘cache reference’이다.
UI 위를 클릭했을 때 월드 오브젝트 선택을 막는 가장 안전한 방법은 무엇인가?
EventSystem.current.IsPointerOverGameObject는 가장 단순한 1차 필터다. 마우스 기준이면 이 한 줄로 대부분 해결된다. 터치까지 포함하면 fingerId 기반 체크가 필요하고, 멀티 터치에서는 어느 포인터가 UI 위인지 구분해야 한다. 더 강한 제어가 필요하면, UI 레이캐스트 결과를 직접 받아서(GraphicRaycaster) UI가 맞았을 때 월드 쿼리를 스킵하는 방식이 확실하다. 중요한 점은 ‘UI가 입력을 소비하니 월드는 자동으로 멈출 것’이라는 가정이 Unity 입력 모델과 맞지 않는다는 점이다. 학습 키워드는 ‘EventSystem’, ‘GraphicRaycaster’, ‘Input System UI Module’이다.
Raycast가 뒤에 있는 오브젝트를 선택하거나, 투명한 오브젝트 뒤가 선택되는 이유는 무엇인가?
Raycast는 렌더링 결과를 기준으로 하지 않고, 물리 콜라이더 교차를 거리 순으로 판단한다. 투명/불투명, 렌더 큐, 셰이더 알파는 물리와 무관하다. 앞에 보이는 오브젝트에 콜라이더가 없거나, 레이어마스크에서 제외되어 있거나, 트리거만 있고 Ignore로 걸러졌다면, 그 뒤 콜라이더가 선택된다. 해결은 ‘보이는 것과 맞힐 것’을 일치시키는 작업이다. 클릭 대상은 단순 콜라이더를 붙이고, 레이어마스크를 명확히 하며, 필요하면 복수 히트를 받아서(NonAlloc 버퍼) 커스텀 우선순위를 적용한다. 학습 키워드는 ‘physics vs rendering’, ‘layer mask’, ‘custom hit sorting’이다.
2D 게임에서도 ScreenPointToRay를 쓰나? Physics2D는 어떻게 연결되나?
2D에서는 Physics2D.Raycast가 별도 물리 월드를 사용한다(Box2D 계열). 화면 좌표를 월드로 바꾸는 방식은 두 갈래다. 하나는 Camera.ScreenToWorldPoint로 마우스 위치를 월드 좌표로 만든 뒤, Physics2D.OverlapPoint 같은 쿼리를 쓰는 방식이다. 다른 하나는 ScreenPointToRay로 Ray를 만든 뒤, Ray의 origin과 direction을 이용해 2D 레이캐스트를 구성하는 방식이다(보통 z=0 평면 기준). 중요한 점은 3D Physics.Raycast와 2D Physics2D.Raycast가 서로의 콜라이더를 절대 맞히지 않는다는 점이다. 2D Collider2D가 붙어 있으면 3D Raycast는 항상 실패한다. 학습 키워드는 ‘Physics2D’, ‘OverlapPoint’, ‘Collider2D vs Collider’이다.