유니티 입문2026년 03월 02일· 9 min read

16. Unity Raycast로 클릭·터치 입력을 월드 오브젝트 선택으로 연결하기

Unity에서 클릭·터치 입력을 Raycast로 월드 오브젝트 선택으로 구현한다. PlayerLoop 시점, C#→C++ 바인딩, PhysicsScene 쿼리, GC·성능 함정까지 다룬다. 초보도 원리로 이해한다. 왜 이렇게 동작하는지 엔진 관점으로 설명한다.

16. Unity Raycast로 클릭·터치 입력을 월드 오브젝트 선택으로 연결하기

Unity Raycast로 클릭·터치 입력을 월드 오브젝트 선택으로 연결하기

게임 만들다 보면 클릭했는데 다른 오브젝트가 선택되거나, UI 버튼을 눌렀는데 뒤의 3D 오브젝트가 같이 선택되는 사고가 터진다. 더 골치 아픈 건 프레임이 떨어질 때 입력이 씹히는 현상이다. Raycast는 이 문제를 ‘스크린 좌표 → 카메라 레이 → 물리 월드 쿼리’로 풀어준다. 동작 원리를 엔진 관점에서 잡아두면, 왜 Update에서 처리해야 하는지, 왜 레이어마스크가 성능에 직결되는지까지 자연스럽게 연결된다. 초보라도 재현 가능한 실습으로 확인한다. 엔진이 내부적으로 어떤 순서로 처리하는지까지 이어진다.

핵심 개념

클릭/터치 입력은 화면 좌표(픽셀)이고, 선택은 월드 좌표(미터)이다. 이 둘을 연결하는 가장 단단한 방법이 Raycast이다. 카메라가 화면의 한 점을 통과하는 광선을 만들고, 물리 월드의 콜라이더와 교차 테스트를 한다. 교차한 첫 번째 콜라이더가 ‘사용자가 찍은 대상’이 된다.

Raycast가 필요한 이유는 ‘렌더링 결과’와 ‘물리 월드’가 다른 시스템이기 때문이다. 화면에 보인다고 해서 선택 가능한 것은 아니다. MeshRenderer는 렌더링용이고, Collider는 물리 쿼리용이다. 선택을 안정적으로 만들려면 “렌더링되는 픽셀”이 아니라 “물리 월드에 존재하는 충돌 형상”을 기준으로 삼아야 한다.

핵심 용어를 짚는다. Ray는 원점(origin)과 방향(direction)으로 구성된 수학 객체이다. RaycastHit은 충돌 결과(충돌 지점, 법선, 거리, collider 참조)를 담는 구조체이다. LayerMask는 물리 쿼리에서 검사할 후보를 줄이는 비트마스크이다. PhysicsScene은 씬 단위의 물리 월드 컨텍스트이며, Physics.Raycast는 내부적으로 기본 PhysicsScene에 쿼리를 던진다.

초보가 가장 많이 헷갈리는 지점은 ‘카메라’이다. ScreenPointToRay는 Camera가 가진 프로젝션(원근/직교)과 뷰 행렬을 사용해 스크린 좌표를 월드 레이로 만든다. 같은 클릭이라도 카메라가 바뀌면 레이가 완전히 달라진다. 그래서 멀티 카메라(미니맵, 시네머신) 환경에서는 어떤 카메라로 레이를 만들지부터 고정해야 한다.

SimpleRaySelect.cs
1using UnityEngine;
2
3public class SimpleRaySelect : MonoBehaviour
4{
5    [SerializeField] private Camera targetCamera;
6    [SerializeField] private LayerMask selectableLayers = ~0;
7    
8    void Update()
9    {
10        if (targetCamera == null) targetCamera = Camera.main;
11        if (!Input.GetMouseButtonDown(0)) return;
12
13        Ray ray = targetCamera.ScreenPointToRay(Input.mousePosition);
14        if (Physics.Raycast(ray, out RaycastHit hit, 1000f, selectableLayers, QueryTriggerInteraction.Ignore))
15        {
16            Debug.Log($"Hit: {hit.collider.name} at {hit.point} dist={hit.distance:F3}");
17        }
18        else
19        {
20            Debug.Log("Hit: none");
21        }
22    }
23}

이 스크립트를 빈 GameObject에 붙이고 Play를 누르면, 콘솔에 Hit: Cube 같은 로그가 찍힌다. 로그가 찍힌다는 사실이 중요한 게 아니라, ‘입력 한 번’이 ‘물리 쿼리 한 번’으로 변환됐다는 것을 확인하는 단계다. 여기서 선택이 안 되면 대부분 Collider가 없거나, 레이어마스크가 빠졌거나, 카메라가 다른 곳을 보고 있다.

한 문단 요약: 클릭/터치 입력은 스크린 좌표이고, 선택은 물리 월드의 콜라이더 교차 테스트로 만든다. ScreenPointToRay가 좌표계를 바꾸고, Physics.Raycast가 PhysicsScene에서 후보를 좁혀 첫 히트를 찾는다. 레이어마스크와 트리거 처리 옵션이 정확도와 비용을 동시에 좌우한다.

엔진 관점에서의 내부 동작

Update에서 Input.GetMouseButtonDown을 읽는 이유는 PlayerLoop에서 입력 상태가 업데이트되는 시점과 맞물리기 때문이다. Unity는 프레임 시작 구간에서 OS 이벤트(마우스/터치)를 수집해 내부 입력 상태를 갱신하고, 그 다음에 스크립트 Update를 호출한다. 그래서 같은 프레임에서 GetMouseButtonDown은 ‘이번 프레임에 눌림’ 상태를 안정적으로 제공한다.

Physics.Raycast는 C#에서 끝나지 않는다. UnityEngine.Physics의 C# API는 내부 호출(바인딩)을 통해 네이티브(C++) 물리 엔진으로 넘어간다. 프로젝트 설정에 따라 백엔드는 PhysX(3D)이며, 레이 쿼리는 broadphase(대략 후보) → narrowphase(정밀 교차) 순서로 진행된다. Collider가 많을수록 broadphase 후보가 커지고, 레이어마스크가 없으면 후보가 폭발한다.

PlayerLoop 관점에서 보면 물리 시뮬레이션은 FixedUpdate 타이밍에 진행되고, Raycast는 ‘현재 물리 월드 스냅샷’에 대한 쿼리다. 즉, Update에서 Raycast를 해도 물리 월드가 Update마다 재시뮬레이션되는 것이 아니다. 마지막 FixedUpdate에서 만들어진 물리 상태(충돌 형상 트랜스폼 포함)를 기준으로 쿼리한다. 그래서 아주 빠르게 움직이는 물체는 프레임 타이밍에 따라 ‘보이는 위치’와 ‘물리 위치’가 어긋날 수 있다.

메모리 관점에서 RaycastHit은 struct라서 C# 힙 할당이 없다. 다만 out RaycastHit은 네이티브에서 결과를 채워 C#으로 복사(마샬링)한다. 이 복사 자체는 작지만, 호출 횟수가 많아지면 누적된다. 프레임당 수백~수천 번 레이를 쏘는 시스템(타워 디펜스 타게팅, 다중 포인터)에서는 RaycastNonAlloc로 결과 배열을 재사용해 비용을 고정시키는 편이 낫다.

왜 QueryTriggerInteraction.Ignore를 자주 쓰는가. 트리거는 물리 충돌 해결에는 참여하지 않지만, 쿼리에는 기본적으로 포함될 수 있다. 선택 시스템에서 트리거를 포함하면 ‘보이지 않는 감지용 콜라이더’가 먼저 맞아서 클릭이 막히는 일이 흔하다. 엔진 입장에서는 트리거도 Collider이므로, 쿼리 단계에서 제외 옵션이 필요하다.

처음에 나도 “왜 UI 위에서 클릭하면 3D 선택이 되지?”가 아니라 반대로 “UI 위에서 클릭했는데 3D가 같이 선택됨” 때문에 3시간 삽질한 적이 있다. 콘솔에는 선택 로그가 정상적으로 찍혔고, EventSystem도 있었는데도 그랬다. 원인은 내가 UI 레이캐스터 차단을 안 하고, Update에서 무조건 Physics.Raycast를 돌린 뒤 선택을 확정해버린 흐름이었다. UI가 입력을 ‘소비’한다는 개념은 자동이 아니라, 개발자가 조건으로 만들어야 한다.

PlayerLoopProbe.cs
1using UnityEngine;
2
3public class PlayerLoopProbe : MonoBehaviour
4{
5    private int _frame;
6
7    void Awake() => Debug.Log("Awake");
8    void OnEnable() => Debug.Log("OnEnable");
9    void Start() => Debug.Log("Start");
10
11    void FixedUpdate()
12    {
13        Debug.Log($"FixedUpdate f={_frame} t={Time.time:F3} ft={Time.fixedTime:F3}");
14    }
15
16    void Update()
17    {
18        _frame++;
19        if (Input.GetMouseButtonDown(0))
20            Debug.Log($"Update MouseDown f={_frame} t={Time.time:F3}");
21    }
22
23    void LateUpdate() => Debug.Log($"LateUpdate f={_frame} t={Time.time:F3}");
24}

Play 후 콘솔을 보면 Awake/OnEnable/Start가 1회 찍히고, FixedUpdate는 0~여러 번 찍힌 뒤 Update/LateUpdate가 1회 찍힌다. 프레임 드랍을 일부러 만들면 FixedUpdate가 한 프레임에 여러 번 돌기도 한다. 입력은 Update에서만 눌림 상태가 잡히는 것을 눈으로 확인할 수 있다. 이게 “왜 입력은 Update, 물리는 FixedUpdate”로 분리됐는지 체감되는 지점이다.

RaycastDebugDraw.cs
1using UnityEngine;
2
3public class RaycastDebugDraw : MonoBehaviour
4{
5    [SerializeField] private Camera targetCamera;
6    [SerializeField] private LayerMask mask = ~0;
7    [SerializeField] private float maxDistance = 50f;
8
9    void Update()
10    {
11        if (targetCamera == null) targetCamera = Camera.main;
12
13        Ray ray = targetCamera.ScreenPointToRay(Input.mousePosition);
14        Debug.DrawRay(ray.origin, ray.direction * maxDistance, Color.yellow);
15
16        if (Input.GetMouseButtonDown(0))
17        {
18            bool hitAny = Physics.Raycast(ray, out RaycastHit hit, maxDistance, mask, QueryTriggerInteraction.Ignore);
19            Debug.Log(hitAny ? $"Hit {hit.collider.name} normal={hit.normal}" : "Hit none");
20        }
21    }
22}

Scene 뷰에서 Gizmos를 켜면 노란 레이가 그려진다. 클릭이 빗나갈 때 가장 먼저 확인할 것은 ‘레이가 진짜 그 오브젝트를 향하는지’이다. 엔진 내부에서 Raycast가 틀린 게 아니라, ScreenPointToRay에 들어가는 카메라가 다른 경우가 훨씬 많다. 특히 Cinemachine이 메인 카메라를 교체하거나, 카메라 태그가 MainCamera가 아닌 경우가 흔하다.

실습하기

1단계: 프로젝트 생성과 입력 환경 고정

Unity Hub → New project → 3D (Core) 템플릿을 선택한다. 버전은 2022.3 LTS 이상을 권장한다. 프로젝트가 열리면 Edit → Project Settings → Player에서 Active Input Handling이 Both 또는 Input Manager(Old)인지 확인한다. 여기서는 Input.mousePosition을 쓰므로 Old 입력이 켜져 있어야 한다.

Scene에는 기본 Main Camera와 Directional Light가 있다. Main Camera를 선택하고 Inspector에서 Tag가 MainCamera인지 확인한다. 이 태그가 빠지면 Camera.main이 내부적으로 매번 태그 검색을 하거나 null이 되어 레이가 엉뚱한 카메라를 쓰게 된다.

CameraMainCache.cs
1using UnityEngine;
2
3public class CameraMainCache : MonoBehaviour
4{
5    private Camera _cam;
6
7    void Awake()
8    {
9        _cam = Camera.main;
10        Debug.Log(_cam != null ? $"Main camera: {_cam.name}" : "Main camera: null");
11    }
12
13    void Update()
14    {
15        if (_cam == null) return;
16        if (Input.GetMouseButtonDown(0))
17            Debug.Log($"Click at {Input.mousePosition} using {_cam.name}");
18    }
19}

콘솔에 Main camera: Main Camera가 찍히면 카메라 기준이 고정된 상태다. Camera.main은 내부적으로 태그 기반 검색을 하고 캐시를 유지하지만, 씬 로드/태그 변경/비활성화에서 캐시가 깨지는 경우가 있다. 그래서 Awake에서 한 번 잡고, null일 때만 재탐색하는 패턴이 실무에서 덜 흔들린다.

2단계: 씬 구성(선택 대상, 레이어, 콜라이더)

Hierarchy 우클릭 → 3D Object → Cube를 3개 만든다. 이름을 Cube_A, Cube_B, Cube_C로 바꾼다. 각각 Position을 (0,0,5), (2,0,6), (-2,0,7)처럼 카메라 앞에 놓는다. Cube에는 기본 BoxCollider가 붙어 있으니 삭제하지 않는다.

레이어를 분리한다. Project 창에서 아무 오브젝트나 선택 → Inspector 상단 Layer → Add Layer… → User Layer 8에 Selectable을 추가한다. Cube_A/B/C를 모두 선택하고 Inspector 상단 Layer를 Selectable로 바꾼다. 레이어마스크로 후보를 줄이면 Raycast의 broadphase 후보가 줄어든다.

SelectableMarker.cs
1using UnityEngine;
2
3public class SelectableMarker : MonoBehaviour
4{
5    [SerializeField] private Renderer targetRenderer;
6    [SerializeField] private Color selectedColor = Color.cyan;
7    private Color _original;
8
9    void Awake()
10    {
11        if (targetRenderer == null) targetRenderer = GetComponent<Renderer>();
12        _original = targetRenderer != null ? targetRenderer.material.color : Color.white;
13    }
14
15    public void SetSelected(bool selected)
16    {
17        if (targetRenderer == null) return;
18        targetRenderer.material.color = selected ? selectedColor : _original;
19    }
20}

이 컴포넌트는 선택 결과를 눈으로 확인하는 장치다. Play 중 선택되면 색이 바뀐다. 여기서 material 접근은 런타임에 인스턴스 머티리얼을 만들 수 있어 드로우콜/메모리에 영향을 준다. 실습에서는 시각 확인이 목적이라 허용하고, 실무에서는 MaterialPropertyBlock로 바꾼다.

3단계: Raycast 선택기 구현과 Play 모드 검증

Hierarchy 우클릭 → Create Empty로 InputSystem(이름) 오브젝트를 만든다. Inspector에서 Add Component → RaycastSelector를 붙인다. targetCamera에는 Main Camera를 드래그한다. selectableLayers는 Selectable만 체크한다. maxDistance는 100으로 둔다.

Play 후 Game 뷰에서 Cube를 클릭하면, 클릭한 큐브만 색이 바뀌고 콘솔에 Selected: Cube_A 같은 로그가 찍힌다. 빈 공간을 클릭하면 선택이 해제되며 Selected: none이 찍힌다. 이 로그는 ‘Raycast가 무엇을 맞췄는지’와 ‘선택 상태 전환이 한 번만 일어나는지’를 동시에 검증한다.

RaycastSelector.cs
1using UnityEngine;
2
3public class RaycastSelector : MonoBehaviour
4{
5    [SerializeField] private Camera targetCamera;
6    [SerializeField] private LayerMask selectableLayers;
7    [SerializeField] private float maxDistance = 100f;
8
9    private SelectableMarker _current;
10
11    void Awake()
12    {
13        if (targetCamera == null) targetCamera = Camera.main;
14    }
15
16    void Update()
17    {
18        if (targetCamera == null) return;
19        if (!Input.GetMouseButtonDown(0)) return;
20
21        Ray ray = targetCamera.ScreenPointToRay(Input.mousePosition);
22        bool hitAny = Physics.Raycast(ray, out RaycastHit hit, maxDistance, selectableLayers, QueryTriggerInteraction.Ignore);
23
24        SelectableMarker next = null;
25        if (hitAny)
26            next = hit.collider.GetComponent<SelectableMarker>();
27
28        if (_current != null) _current.SetSelected(false);
29        _current = next;
30        if (_current != null) _current.SetSelected(true);
31
32        Debug.Log(hitAny && _current != null ? $"Selected: {_current.name}" : "Selected: none");
33    }
34}

여기서 GetComponent가 클릭 때만 호출되도록 만든 이유가 있다. GetComponent는 네이티브에 있는 컴포넌트 리스트를 타입으로 검색한다. Update에서 매 프레임 호출하면, 클릭하지 않아도 프레임당 오버헤드가 생긴다. 클릭 이벤트 기반으로 호출 횟수를 줄이면, 비용은 ‘입력 빈도’로 내려간다.

심화 활용

패턴 1: UI 위 클릭 차단 + 월드 선택 우선순위

UI가 있는 게임은 “UI 클릭인데 월드가 선택됨”을 반드시 막아야 한다. 엔진은 UI와 3D를 자동으로 조율하지 않는다. EventSystem이 먼저 입력을 소비했는지 확인하고, 소비했다면 월드 Raycast를 하지 않는 흐름이 필요하다. 이 조건 하나로 ‘버튼 누르다 유닛이 이동함’ 같은 사고가 사라진다.

RaycastSelectorWithUIBlock.cs
1using UnityEngine;
2using UnityEngine.EventSystems;
3
4public class RaycastSelectorWithUIBlock : MonoBehaviour
5{
6    [SerializeField] private Camera targetCamera;
7    [SerializeField] private LayerMask selectableLayers;
8    private SelectableMarker _current;
9
10    void Awake()
11    {
12        if (targetCamera == null) targetCamera = Camera.main;
13    }
14
15    void Update()
16    {
17        if (!Input.GetMouseButtonDown(0)) return;
18
19        if (EventSystem.current != null && EventSystem.current.IsPointerOverGameObject())
20        {
21            Debug.Log("Pointer over UI: world selection skipped");
22            return;
23        }
24
25        Ray ray = targetCamera.ScreenPointToRay(Input.mousePosition);
26        if (!Physics.Raycast(ray, out RaycastHit hit, 200f, selectableLayers, QueryTriggerInteraction.Ignore))
27        {
28            if (_current != null) _current.SetSelected(false);
29            _current = null;
30            Debug.Log("Selected: none");
31            return;
32        }
33
34        var next = hit.collider.GetComponent<SelectableMarker>();
35        if (_current != null) _current.SetSelected(false);
36        _current = next;
37        if (_current != null) _current.SetSelected(true);
38        Debug.Log(_current != null ? $"Selected: {_current.name}" : "Selected: none (no marker)" );
39    }
40}

Play 후 UI(Canvas + Button)를 하나 올리고 버튼 위를 클릭하면, 콘솔에 Pointer over UI: world selection skipped가 찍힌다. 버튼 클릭 이벤트만 동작하고 큐브 색은 바뀌지 않는다. 처음에 나도 EventSystem이 있으면 자동으로 막힐 줄 착각했다. 자동이 아니라, 개발자가 ‘월드 입력 처리’를 조건으로 끊어야 한다.

패턴 2: RaycastNonAlloc로 GC와 스파이크를 고정

드래그 중 매 프레임 Raycast를 하는 경우가 있다. 예를 들어 RTS에서 마우스 오버 하이라이트, 모바일에서 손가락 따라다니는 타게팅이다. 이때 Raycast를 매 프레임 호출하는 건 가능하지만, 결과 배열을 매번 새로 만들면 GC 스파이크가 생긴다. NonAlloc은 결과를 담을 배열을 재사용하는 방식이다.

HoverRaycastNonAlloc.cs
1using UnityEngine;
2
3public class HoverRaycastNonAlloc : MonoBehaviour
4{
5    [SerializeField] private Camera targetCamera;
6    [SerializeField] private LayerMask mask;
7    [SerializeField] private float maxDistance = 200f;
8
9    private readonly RaycastHit[] _hits = new RaycastHit[8];
10    private SelectableMarker _hover;
11
12    void Awake()
13    {
14        if (targetCamera == null) targetCamera = Camera.main;
15    }
16
17    void Update()
18    {
19        if (targetCamera == null) return;
20
21        Ray ray = targetCamera.ScreenPointToRay(Input.mousePosition);
22        int count = Physics.RaycastNonAlloc(ray, _hits, maxDistance, mask, QueryTriggerInteraction.Ignore);
23
24        SelectableMarker next = null;
25        float bestDist = float.MaxValue;
26        for (int i = 0; i < count; i++)
27        {
28            var m = _hits[i].collider != null ? _hits[i].collider.GetComponent<SelectableMarker>() : null;
29            if (m == null) continue;
30            if (_hits[i].distance < bestDist)
31            {
32                bestDist = _hits[i].distance;
33                next = m;
34            }
35        }
36
37        if (_hover == next) return;
38        if (_hover != null) _hover.SetSelected(false);
39        _hover = next;
40        if (_hover != null) _hover.SetSelected(true);
41    }
42}

Profiler에서 GC Alloc 컬럼을 켜고 마우스를 움직이면, 이 스크립트는 프레임마다 레이를 쏴도 GC Alloc이 0B로 유지되는 것을 확인할 수 있다. 반면 Physics.RaycastAll 같은 API는 내부적으로 배열을 새로 만들어 반환하기 때문에, 호출 빈도가 높으면 GC가 튄다. 실무에서 ‘선택이 순간적으로 끊기는 느낌’의 원인이 GC.Collect 한 번인 경우가 많다.

내 흑역사 하나 더 있다. 모바일에서 터치 드래그로 타게팅 라인을 그리는데, 10초쯤 지나면 한 번씩 프레임이 80ms까지 튀었다. 원인을 못 찾아서 렌더링 최적화만 만지다 시간을 날렸다. 3시간 삽질 끝에 Timeline에서 GC.Collect가 찍혔고, 원인은 Update에서 RaycastAll을 호출해 매 프레임 RaycastHit[]가 생성되던 코드였다.

해결은 두 단계였다. 첫째, RaycastNonAlloc로 바꾸고 결과 배열을 필드로 들고 갔다. 둘째, GetComponent를 히트마다 호출하지 않도록 SelectableMarker를 Collider와 1:1로 캐싱했다(예: Dictionary<Collider, SelectableMarker>). 그 뒤 스파이크가 사라졌고, 드래그 중에도 16.6ms 프레임 예산이 유지됐다.

자주 하는 실수

1) Collider가 없는데 Raycast가 맞을 거라 기대함

증상: 화면에서 분명히 보이는 오브젝트를 클릭해도 콘솔에 Hit none만 찍힌다. Scene 뷰에서 레이는 오브젝트를 통과하는 것처럼 보인다.

원인: Renderer는 렌더링 파이프라인에만 존재하고, Physics.Raycast는 Collider만 대상으로 한다. 메시가 아무리 정교해도 Collider가 없으면 물리 월드에는 ‘빈 공간’이다.

해결: 대상 오브젝트에 BoxCollider/SphereCollider/MeshCollider 중 하나를 추가한다. Hierarchy 선택 → Inspector → Add Component → Box Collider. MeshCollider는 비용이 크므로 선택용이면 단순 콜라이더로 대체한다.

2) 레이어마스크를 안 써서 엉뚱한 오브젝트가 먼저 맞음

증상: 클릭하면 바닥(Plane)만 계속 선택되거나, 보이지 않는 볼륨이 먼저 맞아서 유닛이 선택되지 않는다. RaycastHit.collider.name이 항상 같은 값으로 찍힌다.

원인: Raycast는 ‘가장 가까운 히트’를 반환한다. 바닥이 카메라에 더 가깝거나, 큰 콜라이더가 앞에 있으면 항상 그게 먼저 맞는다. 엔진은 의도를 모른다.

해결: 선택 대상만 Selectable 레이어로 분리하고 LayerMask로 제한한다. 필요하면 바닥은 Ground 레이어로 두고, 이동 지점은 별도 Raycast로 처리한다(선택용, 이동용을 분리).

3) UI 위에서 월드 선택이 같이 발생함

증상: 버튼을 눌렀는데 뒤의 3D 오브젝트 색이 바뀌거나, 캐릭터가 이동한다. QA에서 “UI 누르기 어렵다”가 바로 나온다.

원인: EventSystem이 있다고 해서 Physics.Raycast를 자동으로 막지 않는다. 입력은 한 번이고, UI 레이캐스트와 3D 레이캐스트를 둘 다 돌리면 둘 다 반응한다.

해결: EventSystem.current.IsPointerOverGameObject() 조건으로 월드 입력 처리를 조기 종료한다. 모바일 멀티터치는 pointerId 기반 체크가 필요하니 Input System을 쓰면 함께 바꾼다.

4) Update에서 GetComponent를 매번 호출해 프레임이 흔들림

증상: 선택 시스템을 넣은 뒤 Profiler에서 Script.Update가 0.3~1.5ms까지 늘어난다. 오브젝트가 많아질수록 더 나빠진다.

원인: GetComponent는 네이티브 컴포넌트 리스트를 타입으로 검색한다. 호출 자체가 힙 할당을 만들진 않아도, 프레임당 반복되면 누적 비용이 된다. 특히 히트마다 여러 컴포넌트를 찾으면 더 커진다.

해결: 클릭/터치 이벤트에만 GetComponent를 호출하거나, Awake에서 캐싱한다. Collider→SelectableMarker 매핑을 Dictionary로 만들어 두면 히트 처리에서 검색 비용이 상수로 떨어진다.

5) FixedUpdate에서 입력을 읽어서 클릭이 씹힘

증상: 빠르게 클릭하면 선택이 가끔 안 된다. 프레임이 떨어질수록 더 자주 발생한다. 로그를 찍으면 MouseDown이 들쭉날쭉이다.

원인: FixedUpdate는 물리 스텝 기반으로 호출된다. 입력 상태 갱신은 프레임 기반(Update)과 맞물려서 들어오므로, FixedUpdate에서 GetMouseButtonDown은 타이밍이 어긋나기 쉽다.

해결: 입력 샘플링은 Update에서 하고, 물리 기반 처리가 필요하면 ‘입력 플래그’를 저장한 뒤 FixedUpdate에서 소비한다. 선택은 대개 물리 쿼리지만 시뮬레이션이 아니라 쿼리이므로 Update에서 처리해도 충분하다.

InputInUpdateConsumeInFixed.cs
1using UnityEngine;
2
3public class InputInUpdateConsumeInFixed : MonoBehaviour
4{
5    private bool _click;
6
7    void Update()
8    {
9        if (Input.GetMouseButtonDown(0))
10            _click = true;
11    }
12
13    void FixedUpdate()
14    {
15        if (!_click) return;
16        _click = false;
17        Debug.Log($"Consumed click in FixedUpdate at fixedTime={Time.fixedTime:F3}");
18    }
19}

이 패턴은 ‘입력은 Update에서 샘플링’이라는 원칙을 지키면서, 물리 스텝에서 처리해야 하는 로직(예: Rigidbody 힘 적용)을 안정적으로 연결한다. 선택은 Update에서 끝내는 편이 일반적이지만, 물리 반응까지 이어지는 경우에 유효하다.

성능 최적화 체크리스트

  • Raycast 대상 오브젝트에 Collider가 존재하는지(렌더러만으로 선택 불가)
  • 선택용 레이어(Selectable)를 분리하고 Raycast에 LayerMask를 적용했는지
  • QueryTriggerInteraction.Ignore로 감지용 트리거가 클릭을 가로채지 않게 했는지
  • UI가 있는 씬에서 EventSystem.current.IsPointerOverGameObject로 월드 선택을 차단했는지
  • Camera.main을 Update에서 매번 호출하지 않고 Awake에서 캐싱했는지(태그 검색 비용 회피)
  • GetComponent 호출이 클릭/히트 이벤트에만 발생하는지, 매 프레임 반복 호출이 없는지
  • RaycastAll 대신 Raycast 또는 RaycastNonAlloc을 사용해 GC Alloc을 0B로 유지했는지
  • 마우스 오버 같은 매 프레임 쿼리는 NonAlloc + 결과 배열 재사용으로 고정 비용화했는지
  • Profiler에서 Physics.Raycast 관련 비용을 확인했는지(Timeline에서 Physics.Processing/Queries 확인)
  • 씬에 카메라가 여러 대면 어떤 카메라로 ScreenPointToRay를 만들지 명시했는지
  • maxDistance를 무한대 대신 합리적인 값으로 제한했는지(원거리 broadphase 후보 증가 방지)
  • 선택 결과 시각화(색/아웃라인)가 material 인스턴싱으로 과도한 메모리/드로우콜을 만들지 점검했는지

자주 묻는 질문

Raycast는 Update에서 해야 하나, FixedUpdate에서 해야 하나?

입력 샘플링은 Update가 기준이다. Unity가 OS 이벤트를 모아 내부 입력 상태를 갱신한 뒤 스크립트 Update를 호출하는 흐름이기 때문에, GetMouseButtonDown 같은 ‘이번 프레임에 눌림’은 Update에서 안정적으로 잡힌다. FixedUpdate는 물리 스텝 호출이라 프레임과 1:1이 아니고, 프레임 드랍이 나면 한 프레임에 여러 번 돌거나 아예 건너뛰는 느낌을 준다. Raycast 자체는 물리 시뮬레이션이 아니라 ‘현재 PhysicsScene 스냅샷에 대한 쿼리’라 Update에서 호출해도 된다. 다만 클릭으로 Rigidbody에 힘을 주는 등 물리 반응을 넣는다면 Update에서 입력 플래그를 저장하고 FixedUpdate에서 소비하는 방식이 안전하다. 다음 학습 키워드는 PlayerLoop, Fixed Timestep, PhysicsScene 쿼리이다.

Physics.Raycast를 호출하면 엔진 내부에서 어떤 경로로 처리되나?

C#의 UnityEngine.Physics.Raycast는 관리 코드에서 네이티브 바인딩을 호출해 C++ 런타임으로 넘어간다. 네이티브 쪽에서는 기본 PhysicsScene(씬별 물리 월드)을 대상으로 레이 쿼리를 수행한다. 쿼리는 대개 broadphase에서 레이와 교차 가능성이 있는 콜라이더 후보를 빠르게 걸러낸 뒤, narrowphase에서 실제 형상과의 교차를 정밀 계산한다. LayerMask는 broadphase 후보를 줄이는 강력한 필터라 비용에 직접 영향을 준다. hit 결과는 네이티브 구조체에서 채워진 뒤 RaycastHit struct로 관리 코드에 복사된다. 이 복사는 힙 할당을 만들지 않지만 호출 횟수가 많으면 누적 비용이 된다. 다음 학습 키워드는 PhysX broadphase/narrowphase, Unity native bindings, Physics Debug Visualization이다.

RaycastHit은 GC Alloc을 발생시키나?

RaycastHit은 struct라서 그 자체로는 힙 할당이 없다. out RaycastHit으로 받으면 스택/레지스터 기반으로 값이 채워지고, 네이티브에서 관리 코드로 값 복사가 일어난다. 그래서 단발성 클릭 처리에서는 GC Alloc이 0B로 유지되는 경우가 대부분이다. GC가 생기는 흔한 지점은 RaycastAll처럼 배열을 새로 만들어 반환하는 API, LINQ/foreach 박싱, 문자열 연결로 인한 임시 string 생성, 그리고 머티리얼 접근으로 인한 인스턴스 생성 같은 주변 코드다. 드래그/호버처럼 매 프레임 쿼리를 돌리면 RaycastNonAlloc과 배열 재사용으로 ‘할당을 0으로 고정’하는 설계가 필요하다. 다음 학습 키워드는 GC Alloc, RaycastNonAlloc, Profiler Timeline/GC.Collect 이벤트이다.

UI 위에서 클릭하면 월드 선택이 같이 되는 이유는 무엇인가?

입력은 하나지만 처리 파이프라인은 여러 개다. UI는 GraphicRaycaster + EventSystem을 통해 별도의 레이캐스트를 수행하고, 3D 선택은 개발자가 Physics.Raycast를 호출해 수행한다. 둘은 자동으로 서로를 차단하지 않는다. 그래서 버튼을 눌렀을 때도 Update에서 Physics.Raycast를 실행하면, UI 이벤트와 월드 선택이 동시에 발생한다. 해결은 ‘UI가 포인터를 점유 중이면 월드 입력을 스킵’이라는 정책을 코드로 넣는 것이다. EventSystem.current.IsPointerOverGameObject()는 가장 흔한 방식이고, 모바일 멀티터치에서는 pointerId 기반 체크가 필요하다. 다음 학습 키워드는 EventSystem, GraphicRaycaster, Input System pointerId, UI와 월드 입력 라우팅이다.

왜 레이어마스크가 성능에 그렇게 큰 영향을 주나?

물리 쿼리 비용은 ‘검사 후보 수’에 비례한다. LayerMask를 쓰면 broadphase 단계에서 아예 후보군을 줄여서, 이후 narrowphase 정밀 계산으로 넘어가는 콜라이더 수가 줄어든다. 반대로 아무 레이어나 다 검사하면 바닥, 트리거 볼륨, 이펙트용 콜라이더, 보이지 않는 영역 콜라이더까지 전부 후보가 된다. 특히 큰 월드에서 콜라이더가 수천 개가 되면 Raycast 한 번이 0.05ms가 아니라 0.3ms 이상으로 튈 수 있고, 이를 프레임당 여러 번 하면 16.6ms 예산을 쉽게 넘는다. 레이어 분리는 정확도(엉뚱한 히트 방지)와 비용(후보 감소)을 동시에 잡는 도구다. 다음 학습 키워드는 Physics Layer Collision Matrix, broadphase, 씬 분할(PhysicsScene)이다.

GetComponent를 캐싱하면 왜 빨라지나?

GetComponent는 ‘해당 GameObject가 가진 컴포넌트 목록에서 타입을 찾아 반환’하는 호출이다. 이 목록은 네이티브 쪽에 있고, 호출 시 타입 비교와 경계를 넘는 바인딩 비용이 발생한다. 클릭처럼 드문 이벤트에만 쓰면 문제가 없지만, Update에서 매 프레임 GetComponent를 호출하면 호출 횟수 자체가 비용이 된다. 캐싱은 검색을 한 번으로 줄이고, 이후에는 C# 참조를 직접 사용하므로 경계 통과와 검색 비용이 사라진다. 실무에서는 Awake/Start에서 캐싱하거나, Collider→컴포넌트 매핑을 Dictionary로 만들어 히트 처리 경로에서 GetComponent를 제거한다. 다음 학습 키워드는 Unity native object layout, component lookup cost, caching patterns, Dictionary 메모리 트레이드오프이다.

모바일 터치는 Input.mousePosition으로 처리해도 되나?

에디터와 단순 모바일 빌드에서는 Input.mousePosition이 첫 번째 터치 좌표처럼 동작하는 경우가 많아 빠르게 검증할 수 있다. 하지만 멀티터치(두 손가락 확대/회전)나 UI 포인터 분리, 터치별 pointerId 추적이 필요해지면 Old Input만으로는 코드가 급격히 복잡해진다. Input System 패키지를 쓰면 Touchscreen.current, Pointer, EnhancedTouch로 터치별 상태를 분리해 읽을 수 있고, UI 차단도 포인터별로 처리하기가 수월해진다. 월드 선택 자체는 동일하게 ScreenPointToRay로 연결되며, 달라지는 건 ‘어떤 스크린 좌표를 선택 좌표로 쓸지’와 ‘UI가 점유한 포인터인지’의 판별이다. 다음 학습 키워드는 Unity Input System, EnhancedTouch, pointerId, UI Input Module이다.

관련 글

16. Unity Raycast로 클릭·터치 입력을 월드 오브젝트 선택으로 연결하기

16. Unity Raycast로 클릭·터치 입력을 월드 오브젝트 선택으로 연결하기

Unity에서 클릭·터치 입력을 Raycast로 월드 오브젝트 선택으로 구현한다. PlayerLoop 시점, C#→C++ 바인딩, PhysicsScene 쿼리, GC·성능 함정까지 다룬다. 초보도 원리로 이해한다. 왜 이렇게 동작하는지 엔진 관점으로 설명한다.

3. Unity Raycast로 클릭·터치 입력 감지 구현과 엔진 내부 흐름

3. Unity Raycast로 클릭·터치 입력 감지 구현과 엔진 내부 흐름

Unity Raycast로 클릭·터치 입력을 감지하는 방법과 C#→네이티브 물리 쿼리 흐름, PlayerLoop 타이밍, GC/성능 함정을 함께 다룬다. 레이어 마스크와 UI 충돌도 포함한다. 수치 기반 최적화 기준 제공한다. 실습 코드 포함한다. 모바일 대응 포함.

6. Unity Raycast로 마우스 클릭 오브젝트 선택하기: ScreenPointToRay 내부까지

6. Unity Raycast로 마우스 클릭 오브젝트 선택하기: ScreenPointToRay 내부까지

Unity Camera.ScreenPointToRay와 Physics.Raycast로 마우스 클릭 오브젝트 선택을 구현한다. C#→네이티브 바인딩, PlayerLoop 타이밍, GC/성능 함정까지 연결한다. 3D/2D 차이와 레이어마스크, UI 차단도 포함한다.