2. Unity Raycast란 무엇인가: 클릭 판정부터 엔진 내부 물리 쿼리까지
Unity Raycast의 목적, Physics/Physics2D 내부 처리 흐름, Player Loop 시점, GC·성능 함정과 최적화 패턴까지 엔진 관점으로 설명한다. 초보도 재현 가능한 실습 포함. 15년차 클라 개발 기준 팁 제공. 60fps 기준 호출 한계도 제시.
Unity Raycast란 무엇인가: 클릭 판정부터 엔진 내부 물리 쿼리까지
게임 만들다 보면 ‘마우스로 큐브를 클릭했는데 아무 반응이 없다’ 같은 사고가 실제로 터진다. Game 뷰에서는 분명 큐브 위를 찍는데, RaycastHit가 비어 있고, 심지어 Debug.DrawRay도 맞는 방향으로 그려진다. 원인은 대개 입력 좌표가 아니라 물리 월드 쿼리를 언제·어떻게 던졌는지에 있다. Raycast는 화면 입력, AI 시야, 총알 궤적 같은 문제를 “물리 엔진에게 질문”으로 바꾸는 도구이고, 그 질문이 Player Loop와 네이티브 물리 스텝에 묶여서 동작한다.
핵심 개념
Raycast는 ‘한 점에서 한 방향으로 뻗는 선(또는 선분)’을 물리 월드에 던져서, 그 선이 처음으로 맞는 콜라이더를 찾아 달라는 쿼리이다. 클릭 판정, 상호작용(문 열기), 총알 히트스캔, AI 시야 체크, 지면 체크(캐릭터가 땅에 붙어 있는지) 같은 기능이 Raycast 한 번으로 연결된다. 핵심은 “렌더링 결과”가 아니라 “물리 월드(콜라이더)”에 묻는다는 점이다.
왜 필요한가부터 잡아야 한다. 화면에서 보이는 메시(삼각형)와 충돌에 쓰는 콜라이더는 별개인 경우가 많다. 렌더링은 GPU가, 충돌은 CPU 물리 엔진이 처리한다. 그래서 ‘보이는데 안 맞는다’는 상황은 흔하다. Raycast는 입력 좌표를 물리 월드 좌표/방향으로 변환하고, 물리 월드에 존재하는 콜라이더 데이터 구조를 탐색해 결과를 돌려준다.
용어를 실제 맥락으로 묶는다. Ray(원점+방향)는 질문의 형태, RaycastHit는 답변(맞은 콜라이더, 월드 좌표, 법선, 거리)이다. maxDistance는 질문의 유효 거리이고, LayerMask는 ‘어떤 그룹만 맞출지’ 필터이다. QueryTriggerInteraction는 트리거 콜라이더를 쿼리에 포함할지 결정한다. 이 옵션 하나로 ‘트리거 존을 무시하고 벽만 맞추기’ 같은 의도가 코드에 박힌다.
Raycast는 매 프레임 던질 수 있지만, 던지는 위치가 중요하다. 입력 기반(마우스 클릭, 터치)은 Update에서 던지는 경우가 많고, 물리 기반(고정 시간 스텝에 맞춘 캐릭터 지면 체크)은 FixedUpdate에서 던지는 경우가 많다. 이유는 물리 월드가 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 = 100f;
8
9 private void Reset()
10 {
11 targetCamera = Camera.main;
12 }
13
14 private void Update()
15 {
16 if (targetCamera == null) return;
17 if (!Input.GetMouseButtonDown(0)) 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:F3}");
23 Debug.DrawLine(ray.origin, hit.point, Color.green, 1f);
24 }
25 else
26 {
27 Debug.Log("Hit: none");
28 Debug.DrawRay(ray.origin, ray.direction * maxDistance, Color.red, 1f);
29 }
30 }
31}이 코드를 실행하면, 클릭한 오브젝트가 콜라이더를 갖고 있고 LayerMask에 포함되어 있으면 콘솔에 Hit: Cube 같은 로그가 찍힌다. 반대로 메시만 있고 콜라이더가 없으면 Game 뷰에서 보이더라도 Hit: none이 찍힌다. Debug.DrawRay는 ‘질문이 어떤 형태로 던져졌는지’를 눈으로 확인시키는 장치이고, 여기서 방향이 맞는데도 안 맞는다면 원인은 대부분 레이어/트리거/콜라이더/물리 씬 분리 중 하나이다.
엔진 관점에서의 내부 동작
C#에서 Physics.Raycast를 호출하면, 관리 코드가 직접 충돌 계산을 하지 않는다. UnityEngine.Physics는 네이티브 엔진(C++ 런타임)의 물리 모듈로 바인딩된 래퍼 계층이다. 호출 경로는 대략 ‘C# API → 내부 호출(Injected/ICall 계열) → 네이티브 PhysicsScene 쿼리 → 결과를 C# 구조체로 채움’ 순서로 흐른다. 여기서 중요한 포인트는 RaycastHit가 구조체라서 힙 할당이 아니라 스택/인라인으로 다뤄질 가능성이 높고, 대신 네이티브↔관리 경계에서 값 복사가 일어난다는 점이다.
Player Loop 관점에서 Raycast는 ‘언제 호출해도 되는 즉시 쿼리’처럼 보이지만, 내부적으로는 ‘현재 물리 월드 스냅샷’에 대한 질문이다. 물리 월드는 FixedUpdate 타이밍에 일정 간격(Time.fixedDeltaTime)으로 시뮬레이션되고, 그 결과(트랜스폼 동기화, 콜라이더 AABB 갱신)가 렌더링/스크립트 쪽과 맞물린다. Update에서 Raycast를 던지면, 그 시점의 물리 상태는 ‘마지막 FixedUpdate 이후’의 상태일 가능성이 높다.
왜 Update와 FixedUpdate가 분리되어 있나에 Raycast가 직결된다. 물리는 안정적인 적분을 위해 고정 시간 스텝을 선호하고, 프레임 레이트는 가변이다. 그래서 엔진은 PlayerLoop 안에서 ‘FixedUpdate(0회~여러 회 반복) → Update(1회) → LateUpdate → 렌더’ 같은 구조를 만든다. Raycast를 지면 체크에 쓰면서 Update에서만 던지면, 빠르게 움직일 때 한 프레임 동안 물리 월드가 갱신되지 않아 발이 공중에 떠 있는 상태로 판정되는 식의 미세 버그가 생긴다.
네이티브 쪽에서 Raycast는 보통 broadphase(대략적인 후보 찾기)와 narrowphase(정밀 교차 테스트)로 나뉜다. broadphase는 AABB 트리나 SAP 같은 구조로 레이가 지나갈 가능성이 있는 콜라이더 후보를 줄이고, narrowphase는 실제 콜라이더 모양(박스/스피어/캡슐/메시)에 대해 교차 계산을 한다. LayerMask는 broadphase 후보를 줄이는 데 직접적인 영향을 줘서, ‘레이어 필터링을 잘하면 CPU 시간이 줄어드는’ 이유가 된다.
메모리 관점에서 Raycast 자체는 큰 할당을 만들지 않는 편이지만, ‘결과를 배열로 받는 API’나 ‘LINQ/문자열 합치기 로그’가 끼면 GC Alloc이 튄다. 예를 들어 Physics.RaycastAll은 히트 개수만큼 새 배열을 만들어 반환하는 경우가 많다. 프레임마다 RaycastAll을 쓰면 60fps 환경에서 1초에 60개의 배열이 생기고, 히트가 많으면 배열 크기도 커져서 GC가 눈에 띄게 튄다.
처음에 나도 ‘Raycast는 그냥 선 쏘는 거니까 비용이 거의 0’이라고 착각했다. 모바일 프로젝트에서 Update마다 RaycastAll로 UI 뒤의 적을 찾는 코드를 넣었고, Profiler에서 GC.Alloc이 프레임당 3~10KB로 찍혔다. 3시간 삽질 끝에 원인이 RaycastAll의 결과 배열 생성이라는 걸 찾았고, RaycastNonAlloc으로 바꾸자 GC.Alloc이 0으로 떨어졌다. 프레임 타임도 0.4ms 정도 줄었다(기기: 중급 안드로이드, 히트 후보가 많은 씬).
1using UnityEngine;
2
3public class PlayerLoopRaycastTiming : MonoBehaviour
4{
5 [SerializeField] private Transform rayOrigin;
6 [SerializeField] private float distance = 2f;
7 [SerializeField] private LayerMask groundMask;
8
9 private int fixedCount;
10 private int updateCount;
11
12 private void Awake()
13 {
14 if (rayOrigin == null) rayOrigin = transform;
15 }
16
17 private void FixedUpdate()
18 {
19 fixedCount++;
20 bool hit = Physics.Raycast(rayOrigin.position, Vector3.down, distance, groundMask);
21 Debug.Log($"FixedUpdate[{Time.frameCount}] fixedCount={fixedCount} hit={hit}");
22 }
23
24 private void Update()
25 {
26 updateCount++;
27 bool hit = Physics.Raycast(rayOrigin.position, Vector3.down, distance, groundMask);
28 Debug.Log($"Update[{Time.frameCount}] updateCount={updateCount} hit={hit}");
29 }
30}이 코드를 실행하면 같은 frameCount에서도 FixedUpdate 로그가 0회 또는 여러 회 찍히는 순간이 생긴다(특히 프레임이 흔들릴 때). 지면 체크가 FixedUpdate에서 더 ‘물리 스텝과 일치하는’ 이유가 여기서 체감된다. Update에서 hit가 false인데 FixedUpdate에서 true로 찍히는 프레임이 나오면, 그 프레임은 물리 월드 스냅샷 차이로 판정이 엇갈린 것이다.
1using UnityEngine;
2
3public class RaycastAllocComparison : MonoBehaviour
4{
5 [SerializeField] private Transform origin;
6 [SerializeField] private float distance = 50f;
7 [SerializeField] private LayerMask mask = ~0;
8
9 private readonly RaycastHit[] hitsBuffer = new RaycastHit[16];
10
11 private void Awake()
12 {
13 if (origin == null) origin = transform;
14 }
15
16 private void Update()
17 {
18 if (Input.GetKeyDown(KeyCode.Alpha1))
19 {
20 var hits = Physics.RaycastAll(origin.position, origin.forward, distance, mask);
21 Debug.Log($"RaycastAll hits={hits.Length} (배열 생성 가능성 높음)");
22 }
23
24 if (Input.GetKeyDown(KeyCode.Alpha2))
25 {
26 int count = Physics.RaycastNonAlloc(origin.position, origin.forward, hitsBuffer, distance, mask);
27 Debug.Log($"RaycastNonAlloc hits={count} (버퍼 재사용)");
28 }
29 }
30}1 키를 누르면 RaycastAll이 히트 배열을 반환하고, 2 키를 누르면 미리 만든 hitsBuffer에 결과를 채운다. Profiler의 GC Alloc 컬럼을 켜면 차이가 바로 보인다. 배열이 매번 새로 생기면 GC가 언젠가 그 비용을 회수하러 오고, 그 순간 프레임 드랍이 발생한다. NonAlloc 패턴은 ‘쿼리 결과를 담을 메모리를 호출자가 제공한다’는 설계라서, 엔진이 관리 힙을 건드릴 이유가 줄어든다.
실습하기
1단계: 프로젝트 설정
Unity Hub → New project에서 3D(Core) 템플릿을 선택한다. 버전은 2022.3 LTS 또는 2023.2 이상을 권장한다. Project Settings → Physics에서 ‘Queries Hit Triggers’ 체크 상태를 확인한다. 이 옵션은 QueryTriggerInteraction의 기본 동작과 엮여서, 트리거를 맞출지 말지 실습 결과가 달라질 수 있다.
씬은 SampleScene 그대로 써도 되지만, 카메라가 너무 멀면 클릭이 헷갈린다. Hierarchy에서 Main Camera 선택 → Inspector에서 Transform Position을 (0, 6, -10), Rotation을 (25, 0, 0) 정도로 맞춘다. 이렇게 하면 바닥과 큐브가 화면 중앙에 들어온다.
2단계: 씬 구성(오브젝트 배치/레이어/콜라이더)
Hierarchy 우클릭 → 3D Object → Plane을 만든다. 이름을 Ground로 바꾸고, Inspector에서 Layer를 Ground 전용으로 만들고 할당한다(Layer 드롭다운 → Add Layer… → User Layer 8에 Ground 입력 → 다시 Ground 선택). Ground에는 기본으로 MeshCollider가 붙어 있다. 이 콜라이더가 Raycast의 대상이 된다.
Hierarchy 우클릭 → 3D Object → Cube를 만든다. 이름을 ClickTarget으로 바꾸고 Position을 (0, 0.5, 0)으로 둔다. CubeCollider가 기본으로 붙어 있다. Inspector에서 Layer를 Interactable로 하나 더 만들어 할당한다. Raycast가 레이어 필터로 ‘바닥은 무시하고 큐브만 맞추기’를 할 수 있게 준비하는 단계이다.
1using UnityEngine;
2
3public class SceneRaycastSetupGuide : MonoBehaviour
4{
5 [Header("Inspector에서 targetCamera는 Main Camera 드래그")]
6 [SerializeField] private Camera targetCamera;
7
8 [Header("Interactable 레이어만 체크")]
9 [SerializeField] private LayerMask interactableMask;
10
11 [SerializeField] private float maxDistance = 200f;
12
13 private void Update()
14 {
15 if (targetCamera == null) return;
16
17 if (Input.GetMouseButtonDown(0))
18 {
19 Ray ray = targetCamera.ScreenPointToRay(Input.mousePosition);
20 bool ok = Physics.Raycast(ray, out RaycastHit hit, maxDistance, interactableMask, QueryTriggerInteraction.Collide);
21
22 Debug.DrawRay(ray.origin, ray.direction * maxDistance, ok ? Color.green : Color.red, 1.0f);
23
24 if (ok)
25 Debug.Log($"Interactable hit: {hit.collider.name} layer={hit.collider.gameObject.layer}");
26 else
27 Debug.Log("Interactable hit: none (레이어 마스크 또는 콜라이더 확인)");
28 }
29 }
30}이 스크립트를 빈 오브젝트에 붙여서 실습한다. Hierarchy 우클릭 → Create Empty로 RaycastSystem을 만들고, Inspector → Add Component로 SceneRaycastSetupGuide를 추가한다. Inspector에서 Target Camera에는 Main Camera를 드래그한다. Interactable Mask는 Interactable 레이어만 체크한다. Play를 누른 뒤 Cube를 클릭하면 ‘Interactable hit: ClickTarget’이 찍히고, 바닥을 클릭하면 none이 찍힌다. 레이어 필터가 broadphase 후보를 줄여서 쿼리 비용도 같이 줄어든다.
3단계: 결과를 눈으로 확인(히트 포인트/법선/디버그 마커)
RaycastHit.point는 월드 좌표라서, 거기에 작은 구체를 생성하면 ‘정말 그 지점을 맞췄는지’를 확인할 수 있다. 클릭 판정이 맞는데도 오브젝트 반응이 이상하면, 대부분 히트 포인트가 생각보다 앞/뒤로 찍히거나, 다른 콜라이더를 먼저 맞춘 상황이다. 시각화가 디버깅 시간을 압도적으로 줄인다.
Hierarchy 우클릭 → 3D Object → Sphere로 마커 프리팹을 만들고, Scale을 (0.1, 0.1, 0.1)로 줄인 뒤 Project 창으로 드래그해서 Prefab으로 만든다. 씬에 남은 Sphere는 삭제한다. 이 Prefab을 스크립트의 markerPrefab에 연결한다. Play 중 클릭할 때마다 맞은 지점에 구체가 생긴다.
1using UnityEngine;
2
3public class RaycastHitVisualizer : MonoBehaviour
4{
5 [SerializeField] private Camera targetCamera;
6 [SerializeField] private LayerMask mask = ~0;
7 [SerializeField] private float maxDistance = 200f;
8
9 [Header("Project의 Sphere Prefab 드래그")]
10 [SerializeField] private GameObject markerPrefab;
11
12 private void Reset()
13 {
14 targetCamera = Camera.main;
15 }
16
17 private void Update()
18 {
19 if (targetCamera == null || markerPrefab == null) return;
20 if (!Input.GetMouseButtonDown(0)) return;
21
22 Ray ray = targetCamera.ScreenPointToRay(Input.mousePosition);
23 if (Physics.Raycast(ray, out RaycastHit hit, maxDistance, mask, QueryTriggerInteraction.Ignore))
24 {
25 Instantiate(markerPrefab, hit.point, Quaternion.LookRotation(hit.normal));
26 Debug.Log($"point={hit.point} normal={hit.normal} collider={hit.collider.name}");
27 }
28 }
29}이 코드를 실행하면 클릭할 때마다 표면에 마커가 박힌다. 마커가 오브젝트 뒤쪽이나 엉뚱한 곳에 찍히면, 카메라가 아닌 다른 카메라를 참조했거나, 씬에 보이지 않는 콜라이더(큰 트리거 박스, 보이지 않는 벽)가 먼저 맞고 있는 경우가 많다. 콘솔의 collider 이름이 디버깅의 출발점이 된다.
심화 활용
한 문단 요약: Raycast는 렌더링이 아니라 물리 월드에 대한 즉시 쿼리이고, Player Loop에서 ‘현재 물리 스냅샷’에 질문한다. 레이어 필터링과 NonAlloc 버퍼 재사용이 성능과 GC를 좌우한다.
패턴 1: 상호작용 시스템(레이어+거리+우선순위)로 ‘의도한 것만’ 맞추기
실무에서는 ‘문 손잡이를 클릭했는데 뒤의 벽이 선택됨’ 같은 문제가 자주 나온다. Raycast는 “가장 먼저 맞는 콜라이더” 하나를 돌려주기 때문에, 콜라이더 배치/크기/레이어 설계가 곧 UX가 된다. 상호작용 대상은 Interactable 레이어로 분리하고, 거리 제한을 강하게 걸어야 플레이어가 납득하는 결과가 나온다.
1using UnityEngine;
2
3public class InteractRaycaster : MonoBehaviour
4{
5 [SerializeField] private Camera cam;
6 [SerializeField] private float interactDistance = 3.0f;
7 [SerializeField] private LayerMask interactMask;
8
9 private void Reset()
10 {
11 cam = Camera.main;
12 }
13
14 private void Update()
15 {
16 if (cam == null) return;
17
18 if (Input.GetMouseButtonDown(0))
19 {
20 Ray ray = cam.ScreenPointToRay(Input.mousePosition);
21 if (Physics.Raycast(ray, out RaycastHit hit, interactDistance, interactMask, QueryTriggerInteraction.Collide))
22 {
23 var interactable = hit.collider.GetComponent<MonoBehaviour>();
24 Debug.Log($"Interact candidate: {hit.collider.name}, dist={hit.distance:F2}");
25 }
26 else
27 {
28 Debug.Log("No interactable in range");
29 }
30 }
31 }
32}여기서 핵심은 interactDistance이다. 클릭 판정이 멀리까지 뻗으면, 플레이어는 ‘화면에 보이는 거 다 클릭됨’으로 받아들여서 의도하지 않은 오브젝트가 잡힌다. 레이어와 거리로 후보를 줄이면 broadphase 단계에서부터 걸러져서 CPU 비용도 줄고, 무엇보다 결과가 일관된다. GetComponent를 붙인 이유는 ‘히트는 했는데 상호작용 스크립트가 없는 콜라이더’가 섞였을 때 로그로 바로 확인하기 위해서다.
패턴 2: RaycastNonAlloc + 재사용 버퍼로 프레임 GC 0 만들기
총알 히트스캔, AI 시야, 다중 타겟 탐색은 Raycast를 한 프레임에 수십~수백 번 던질 수 있다. 이때 RaycastAll을 쓰면 히트 배열이 계속 생긴다. NonAlloc은 호출자가 버퍼를 제공하므로, 히트 개수 상한을 설계로 박는 대신 런타임 힙 할당을 없앨 수 있다. 버퍼가 꽉 차면 ‘더 많은 히트가 있었는데 잘렸다’는 사실을 감지해야 한다.
1using UnityEngine;
2
3public class NonAllocHitscan : MonoBehaviour
4{
5 [SerializeField] private Transform muzzle;
6 [SerializeField] private float range = 100f;
7 [SerializeField] private LayerMask mask;
8
9 private RaycastHit[] buffer = new RaycastHit[8];
10
11 private void Awake()
12 {
13 if (muzzle == null) muzzle = transform;
14 }
15
16 private void Update()
17 {
18 if (!Input.GetKeyDown(KeyCode.Space)) return;
19
20 int count = Physics.RaycastNonAlloc(muzzle.position, muzzle.forward, buffer, range, mask, QueryTriggerInteraction.Ignore);
21 Debug.DrawRay(muzzle.position, muzzle.forward * range, Color.cyan, 0.5f);
22
23 if (count == buffer.Length)
24 Debug.LogWarning($"Hit buffer full({buffer.Length}). 일부 히트가 잘렸을 수 있음");
25
26 float nearest = float.MaxValue;
27 int nearestIndex = -1;
28
29 for (int i = 0; i < count; i++)
30 {
31 if (buffer[i].distance < nearest)
32 {
33 nearest = buffer[i].distance;
34 nearestIndex = i;
35 }
36 }
37
38 if (nearestIndex >= 0)
39 Debug.Log($"Nearest hit: {buffer[nearestIndex].collider.name} dist={nearest:F2}");
40 else
41 Debug.Log("No hit");
42 }
43}Space를 누르면 히트스캔을 쏘고, 가장 가까운 히트를 선택한다. RaycastNonAlloc은 ‘정렬된 결과’를 보장하지 않는다고 가정하는 편이 안전해서, nearest를 직접 찾는 루프를 넣었다. 이 루프는 히트 개수가 8~16 정도면 비용이 미미하고, 대신 GC를 0으로 만든다. Profiler에서 CPU Usage와 GC Alloc을 같이 켜면, Space 연타 시에도 GC Alloc이 0으로 유지되는지 확인 가능하다.
처음에 나도 RaycastHit 배열 버퍼를 지역 변수로 만들었다가, 성능이 왜 그대로인지 한참 헤맸다. 지역에서 new RaycastHit[32]를 만들면 NonAlloc을 써도 결국 매번 배열이 생긴다. 콘솔에는 아무 문제도 없고, 체감도 미묘해서 더 위험했다. Profiler Timeline에서 GC.Collect가 튀는 프레임을 찍고 Call Stack을 따라가니, 내 코드의 new가 원인이었다.
또 한 번은 ‘트리거는 무시했는데도 트리거가 맞는다’는 리포트를 받았다. 원인은 Project Settings → Physics의 Queries Hit Triggers가 켜져 있었고, 코드에서는 QueryTriggerInteraction.UseGlobal을 쓰고 있었다. 트리거 포함 여부가 프로젝트 전역 설정에 묶여서, 씬마다 결과가 달라지는 상황이 만들어졌다. 이후에는 상호작용/총알/시야 같은 핵심 쿼리는 UseGlobal을 금지하고 Ignore/Collide를 명시했다.
자주 하는 실수
실수 1: 콜라이더가 없는데 ‘보이니까 맞겠지’라고 생각함
증상: Game 뷰에서 오브젝트를 클릭해도 항상 Hit: none이 찍힌다. Debug.DrawRay는 오브젝트를 관통하는데도 RaycastHit가 비어 있다.
원인: 렌더러(MeshRenderer)와 콜라이더(BoxCollider 등)는 별개 컴포넌트이다. 메시가 보여도 콜라이더가 없으면 물리 월드에는 ‘맞출 대상’이 없다.
해결: Hierarchy에서 대상 오브젝트 선택 → Inspector에서 Add Component → Box Collider(또는 Mesh Collider) 추가. Mesh Collider는 비용이 크고 동적 오브젝트에는 제약이 많아서, 클릭 판정은 Box/Sphere/Capsule로 단순화하는 편이 안전하다.
실수 2: LayerMask를 기본값(Everything)으로 두고 ‘왜 벽 뒤 적이 맞지?’라고 당황함
증상: 클릭이나 총알이 앞의 유리(트리거)나 보이지 않는 볼륨을 먼저 맞아서, 뒤의 타겟이 선택되지 않는다. 콘솔에는 Hit collider가 예상과 다르게 찍힌다.
원인: Raycast는 가장 먼저 맞는 콜라이더 하나를 반환한다. 레이어 필터가 없으면 broadphase 후보가 늘어나고, 의도하지 않은 콜라이더가 먼저 걸린다.
해결: 레이어를 역할별로 분리(Interactable, Environment, InvisibleWall 등)하고, LayerMask를 코드에서 강제한다. Inspector에서 LayerMask를 체크박스로 관리하면 실수로 Everything이 들어가는 경우가 많아서, 상수화하거나 ScriptableObject로 정책을 고정하는 팀도 많다.
실수 3: QueryTriggerInteraction/Queries Hit Triggers 조합을 헷갈림
증상: QueryTriggerInteraction.Ignore로 던졌다고 생각했는데 트리거가 맞거나, 반대로 트리거 존 진입 감지를 Raycast로 하려는데 아무것도 안 맞는다.
원인: 코드에서 UseGlobal을 쓰면 Project Settings → Physics → Queries Hit Triggers에 종속된다. 팀원이 프로젝트 설정을 바꾸면 같은 코드가 다른 결과를 낸다.
해결: 핵심 로직의 Raycast는 Ignore/Collide를 명시한다. 트리거를 맞춰야 하는 쿼리(예: 상호작용 범위 트리거)는 Collide로 고정하고, 트리거가 섞이면 안 되는 쿼리(총알/시야)는 Ignore로 고정한다.
실수 4: Update에서 물리 기반 판정을 하고 ‘가끔만’ 튐
증상: 지면 체크가 가끔 false로 튀어서 점프가 두 번 나가거나, 경사면에서 캐릭터가 미세하게 떤다. 재현이 어렵고, 프레임이 떨어질 때 더 자주 나타난다.
원인: 물리 월드는 FixedUpdate 간격으로 갱신된다. Update에서 Raycast를 던지면 ‘마지막 물리 스텝’ 상태에 질문하게 되고, 프레임 드랍 시 FixedUpdate가 여러 번 돌거나 아예 안 도는 프레임이 생겨 판정이 흔들린다.
해결: 지면 체크/벽 붙기/낙하 판정 같은 물리 기반 판정은 FixedUpdate에서 수행하고, 입력은 Update에서 수집한 뒤 FixedUpdate에서 소비한다. 또는 Rigidbody 기반이면 Rigidbody의 위치/속도와 동일한 스텝에서 쿼리를 맞춘다.
실수 5: RaycastAll을 매 프레임 호출하고 GC 스파이크를 못 찾음
증상: Profiler에서 가끔 10~30ms 프레임이 튄다. CPU Usage에는 Physics 쪽이 길게 보이고, GC.Collect가 주기적으로 튄다. 눈으로는 Raycast가 원인인지 감이 안 온다.
원인: RaycastAll은 결과 배열을 반환하는 API라서, 히트 개수에 따라 새 배열이 생길 가능성이 높다. 프레임마다 호출하면 관리 힙이 빠르게 오염되고, GC가 회수하러 오는 순간이 프레임 드랍으로 나타난다.
해결: Physics.RaycastNonAlloc으로 바꾸고, RaycastHit[] 버퍼를 필드로 재사용한다. 버퍼 길이가 부족하면 경고를 띄워서 설계로 상한을 조정한다. Profiler에서 GC Alloc 컬럼이 0인지 확인하고, Deep Profile은 비용이 커서 재현 구간에서만 켠다.
1using UnityEngine;
2
3public class TriggerGlobalPitfall : MonoBehaviour
4{
5 [SerializeField] private Transform origin;
6 [SerializeField] private float distance = 10f;
7 [SerializeField] private LayerMask mask = ~0;
8
9 private void Awake()
10 {
11 if (origin == null) origin = transform;
12 }
13
14 private void Update()
15 {
16 if (!Input.GetKeyDown(KeyCode.T)) return;
17
18 bool useGlobal = Physics.Raycast(origin.position, origin.forward, distance, mask, QueryTriggerInteraction.UseGlobal);
19 bool ignore = Physics.Raycast(origin.position, origin.forward, distance, mask, QueryTriggerInteraction.Ignore);
20 bool collide = Physics.Raycast(origin.position, origin.forward, distance, mask, QueryTriggerInteraction.Collide);
21
22 Debug.Log($"UseGlobal={useGlobal}, Ignore={ignore}, Collide={collide} (Project Settings 영향 확인)");
23 }
24}T를 눌렀을 때 세 결과가 다르게 나오면, 씬에 트리거가 섞여 있고 프로젝트 전역 설정이 결과에 영향을 주고 있다는 뜻이다. 이 로그는 팀 프로젝트에서 ‘내 PC에서는 되는데?’ 상황을 재현하는 데 특히 유용하다.
성능 최적화 체크리스트
- Raycast 대상 오브젝트에 Collider가 붙어 있는지 Inspector에서 확인한다(MeshRenderer만 있으면 히트되지 않음).
- 레이어를 역할별로 분리하고 LayerMask로 후보를 줄인다(Interactable/Environment/Character 등).
- QueryTriggerInteraction을 UseGlobal로 두지 않고 Ignore/Collide를 명시한다(전역 설정 의존 제거).
- 지면 체크 같은 물리 판정은 FixedUpdate에서 수행하고, 입력은 Update에서 수집 후 FixedUpdate에서 소비한다.
- RaycastAll을 매 프레임 호출하지 않는다. 다중 히트가 필요하면 RaycastNonAlloc + 재사용 버퍼를 쓴다.
- RaycastHit[] 버퍼를 지역 new로 만들지 않는다. 필드로 두고 재사용한다(프레임 GC 0 목표).
- maxDistance를 과도하게 크게 두지 않는다(불필요한 후보 증가). 의도 거리(상호작용 2~4m, 총알 50~200m)를 수치로 고정한다.
- Physics Debug Visualization(버전에 따라 Window/Analysis 메뉴)로 콜라이더 배치를 확인한다. 보이지 않는 콜라이더가 먼저 맞는지 점검한다.
- Profiler에서 CPU Usage와 GC Alloc을 같이 본다. Raycast가 의심되면 Physics 카테고리와 GC.Collect 프레임을 같이 캡처한다.
- 카메라 참조를 Camera.main에만 의존하지 않는다(태그 누락/다중 카메라). Inspector로 명시 연결한다.
- 2D/3D 물리를 섞지 않는다. 2D는 Physics2D.Raycast, 3D는 Physics.Raycast를 사용한다.
- 트리거 존 감지 목적이면 Raycast 대신 OnTriggerEnter/Stay가 더 적합한지 검토한다(의도와 비용의 균형).
자주 묻는 질문
Raycast는 매 프레임 써도 괜찮나? 몇 번까지가 한계인가?
Raycast는 ‘선 하나 쏘는 연산’처럼 보여도, 내부에서는 broadphase 후보 수집과 narrowphase 교차 테스트를 수행한다. 한계는 씬의 콜라이더 수, 레이어 필터링, 레이 길이, 콜라이더 타입(메시 콜라이더 비중)에 따라 크게 달라진다. 실무 기준으로는 데스크톱에서 Update당 수십~수백 회도 가능하지만, 모바일에서는 수십 회만으로도 0.2~1ms가 쉽게 나간다. 기준을 잡는 방법은 단순하다. Profiler에서 Physics.Raycast 관련 CPU 시간을 보고, 60fps(프레임 16.6ms)에서 물리에 쓸 수 있는 예산을 1~2ms로 제한한 뒤, 그 안에서 호출 수를 역산한다. 호출 수를 줄이는 1순위는 LayerMask로 후보를 줄이는 것이고, 2순위는 RaycastAll 같은 할당 API를 제거해 GC 스파이크를 없애는 것이다. 다음 학습 키워드는 Physics broadphase, RaycastNonAlloc, Profiler Timeline이다.
왜 클릭 판정은 Update에서 하고, 지면 체크는 FixedUpdate에서 한다고들 하나?
입력은 프레임 단위로 들어오고(Input 시스템이 Update 타이밍에 맞춰 상태를 갱신하는 경우가 많음), 물리 월드는 고정 시간 스텝으로 갱신된다. Raycast는 ‘현재 물리 월드 스냅샷’에 질문하기 때문에, 물리 기반 판정(지면, 벽, 낙하)은 FixedUpdate와 맞추는 편이 결과가 안정적이다. 반면 클릭은 사용자가 버튼을 누른 ‘그 프레임’에 반응해야 UX가 자연스럽다. 그래서 Update에서 클릭을 감지하고 Raycast를 던지되, 그 Raycast가 물리 상태에 민감한 로직(예: Rigidbody 이동 직후 판정)과 엮이면 프레임 흔들림에서 미세 오차가 생길 수 있다. 이 경우 Update에서 입력만 저장하고, FixedUpdate에서 저장된 입력을 소비하면서 Raycast를 던지는 구조가 더 견고하다. 다음 학습 키워드는 PlayerLoop, Fixed Timestep, Rigidbody interpolation이다.
ScreenPointToRay는 엔진 내부에서 뭘 계산하나?
Camera.ScreenPointToRay는 화면 픽셀 좌표를 카메라의 뷰 공간으로 역변환한 뒤, 월드 공간에서의 원점과 방향 벡터를 만든다. 이 과정은 렌더링 파이프라인의 투영 행렬(Projection)과 뷰 행렬(View, 카메라 트랜스폼의 역행렬)을 사용한다. 즉 ‘화면 한 점’이 3D 공간에서 어떤 광선에 해당하는지 수학적으로 복원하는 단계이다. 여기서 중요한 실전 포인트는 카메라가 어떤 카메라인지, 그리고 이벤트 카메라/오버레이 카메라 등 다중 카메라 구성이 있는지다. 다른 카메라로 Ray를 만들면 방향 자체가 달라져서, Debug.DrawRay는 그럴듯해도 전혀 다른 곳을 향한다. 또 UI 위에서 클릭을 처리할 때는 EventSystem이 입력을 먹어서 Raycast가 아예 호출되지 않는 경우도 생긴다. 다음 학습 키워드는 View/Projection matrix, multiple camera, EventSystem raycaster이다.
왜 RaycastAll은 GC를 만들고, RaycastNonAlloc은 GC를 줄이나?
RaycastAll은 ‘히트 결과 전부’를 반환해야 하므로, 반환 컨테이너가 필요하다. C# API 형태가 RaycastHit[]를 반환하는 순간, 그 배열은 관리 힙 객체이고 호출 시점에 새로 만들어질 가능성이 높다. 히트가 많을수록 배열이 커지고, 프레임마다 호출하면 힙이 빠르게 증가한다. GC는 즉시 발생하지 않고 어느 정도 쌓인 뒤 수거 시점에 큰 비용을 내기 때문에, ‘가끔 프레임이 크게 튄다’는 형태로 나타난다. RaycastNonAlloc은 호출자가 RaycastHit[] 버퍼를 제공하고, 엔진은 그 버퍼에 결과를 채운다. 버퍼가 재사용되면 프레임마다 새 배열을 만들 이유가 없어서 GC Alloc이 0에 가까워진다. 단점은 버퍼가 꽉 차면 결과가 잘릴 수 있으니, count==buffer.Length 같은 상황을 감지해 설계를 조정해야 한다. 다음 학습 키워드는 managed heap, GC spikes, NonAlloc patterns이다.
왜 Raycast가 ‘보이는 메시’가 아니라 ‘콜라이더’를 맞추나? 메시를 직접 맞추면 더 정확하지 않나?
렌더링 메시(삼각형)는 GPU가 그리기 위한 데이터이고, 물리 충돌은 CPU에서 빠르게 판정해야 한다. 삼각형 메시를 그대로 충돌에 쓰면 정확도는 올라가지만 비용이 급격히 증가한다. 그래서 Unity는 충돌을 위해 단순한 프리미티브 콜라이더(박스/스피어/캡슐)나 제한적으로 메시 콜라이더를 제공하고, Raycast도 그 콜라이더를 대상으로 한다. 메시 콜라이더는 정적 환경에서는 쓸 만하지만, 동적으로 움직이거나 변형되는 오브젝트에 붙이면 비용과 제약이 커진다(요리된 메시 데이터, 업데이트 비용). ‘보이는데 안 맞는다’는 문제는 대개 콜라이더가 메시와 다르게 생겼거나, 콜라이더가 아예 없어서 생긴다. 정확도가 필요하면 콜라이더를 더 잘 만들거나, 필요한 부분만 메시 콜라이더를 쓰는 식으로 타협한다. 다음 학습 키워드는 primitive collider, mesh collider cooking, physics performance budget이다.
RaycastHit에는 어떤 정보가 들어 있고, 어떤 것들이 비용을 늘리나?
RaycastHit에는 맞은 콜라이더 참조, 맞은 지점(point), 표면 법선(normal), 거리(distance) 같은 값이 들어간다. 이 값들은 네이티브 물리 계산 결과를 관리 코드로 복사해 온 것이다. 일반적으로 단일 Raycast(out RaycastHit)는 큰 힙 할당을 만들지 않지만, hit.collider.gameObject.GetComponent 같은 후속 호출이 비용을 늘릴 수 있다. 특히 Update에서 히트한 오브젝트에 대해 매번 GetComponent를 호출하면, 네이티브 쪽 컴포넌트 배열 탐색과 바인딩 비용이 누적된다. 상호작용 대상은 IInteractable 같은 인터페이스를 캐싱하거나, Collider에 매핑 테이블을 두는 식으로 후속 비용을 줄이는 패턴이 있다. 또 hit 정보를 문자열 보간으로 매 프레임 로그 찍으면 그 문자열이 할당을 만들 수 있어서, 디버그는 조건부로 제한한다. 다음 학습 키워드는 GetComponent cost, component caching, string allocation이다.
2D 게임인데 Physics.Raycast를 쓰면 왜 아무것도 안 맞나?
Unity 2D 물리와 3D 물리는 완전히 다른 월드와 다른 콜라이더 체계를 쓴다. 2D는 Rigidbody2D/Collider2D가 Physics2D 월드에 등록되고, 3D는 Rigidbody/Collider가 Physics 월드에 등록된다. 그래서 2D 씬에서 Collider2D만 있는 오브젝트에 Physics.Raycast(3D)를 던지면 맞을 대상이 없다. 반대로 3D 콜라이더만 있는 씬에서 Physics2D.Raycast를 던져도 결과는 비어 있다. 해결은 단순히 API를 맞추는 것이다. 2D면 Physics2D.Raycast를 쓰고, RaycastHit2D를 받는다. 카메라에서 레이를 만들 때도 2D는 z축 처리(평면 가정) 때문에 별도의 좌표 변환이 필요할 때가 많다. 다음 학습 키워드는 Physics2D.Raycast, RaycastHit2D, 2D collider layers이다.