15. Unity Raycast 원리: Ray·Collider·LayerMask가 엔진에서 처리되는 방식
Unity Raycast가 C#에서 C++ Physics로 넘어가 Collider·LayerMask·트리 탐색으로 처리되는 흐름과 PlayerLoop 타이밍, GC/성능 함정을 실습으로 확인한다. (초보자용)
Unity Raycast 원리: Ray·Collider·LayerMask가 엔진에서 처리되는 방식
게임 만들다 겪는 사고로 제일 흔한 게 ‘분명 앞에 벽이 있는데 클릭이 통과한다’ 같은 상황이다. 마우스로 쏜 Ray가 어떤 프레임에는 맞고 어떤 프레임에는 빗나가고, Trigger만 잔뜩 맞거나, 특정 레이어 오브젝트만 유독 무시된다. 이 현상은 대부분 Raycast가 C#에서 끝나는 기능이 아니라 C++ Physics 월드(브로드페이즈/내로우페이즈)까지 내려가며, LayerMask·Trigger 설정·콜라이더 생성 타이밍이 얽히기 때문에 생긴다. 이 글은 그 흐름을 눈으로 확인하게 만든다.
핵심 개념
Raycast는 ‘광선이 무엇을 맞췄는지’가 아니라 ‘Physics 월드에 등록된 Collider 집합을 어떤 규칙으로 조회했는지’에 더 가깝다. 그래서 Transform만 있는 오브젝트는 절대 맞지 않는다. 반대로 MeshRenderer를 꺼도 Collider가 남아 있으면 맞는다. 게임에서 클릭/조준/시야 판정이 자주 어긋나는 이유가 여기서 시작한다.
Ray는 원점(origin)과 방향(direction)만 가진 값 타입(struct)이다. C#에서 Ray를 만든다고 해서 엔진에 아무것도 생성되지 않는다. 실제 비용은 Physics.Raycast를 호출하는 순간 생기며, 그때 C# 래퍼가 네이티브(C++)로 넘어가 PhysicsScene의 쿼리를 실행한다.
Collider는 물리 시뮬레이션과 ‘쿼리(질의)’의 대상이다. Rigidbody가 없어도 Collider만 있으면 Raycast 쿼리에는 잡힌다. 반대로 Rigidbody만 있고 Collider가 없으면 잡히지 않는다. 초보가 흔히 착각하는 지점이 ‘물리=리지드바디’인데, Raycast는 Collider 인덱싱 문제다.
LayerMask는 ‘충돌 레이어’가 아니라 ‘쿼리 필터 비트마스크’다. Physics.Raycast는 LayerMask로 후보군을 먼저 줄인다. 이 필터링은 브로드페이즈 단계에서 크게 작동해서, 레이어를 제대로 나누면 Raycast 비용이 체감될 정도로 내려간다. 반대로 Everything(=-1)로 쏘면 씬이 커질수록 비용이 선형이 아니라 로그+상수(트리 탐색)로 늘어도 결국 많이 쏘면 프레임이 무너진다.
RaycastHit는 ‘맞았는지’뿐 아니라 ‘어느 지점에서, 어떤 노멀로, 어떤 Collider를’ 알려주는 결과 구조체다. 다만 hit.collider 같은 프로퍼티 접근은 내부적으로 네이티브 오브젝트를 C# 래퍼(UnityEngine.Object)로 매핑하는 과정이 끼어든다. 히트가 많고 결과를 매 프레임 다 만지면 비용이 튄다.
1using UnityEngine;
2
3public class RaycastBasics : MonoBehaviour
4{
5 [Header("Ray 설정")]
6 public Camera cam;
7 public float maxDistance = 50f;
8 public LayerMask hitMask = ~0; // Everything
9
10 void Update()
11 {
12 if (cam == null) cam = Camera.main;
13
14 Ray ray = cam.ScreenPointToRay(Input.mousePosition);
15 bool hit = Physics.Raycast(ray, out RaycastHit hitInfo, maxDistance, hitMask, QueryTriggerInteraction.Ignore);
16
17 Debug.DrawRay(ray.origin, ray.direction * maxDistance, hit ? Color.green : Color.red);
18 if (hit)
19 {
20 Debug.Log($"Hit: {hitInfo.collider.name} point={hitInfo.point} layer={hitInfo.collider.gameObject.layer}");
21 }
22 }
23}이 코드를 실행하면 Scene 뷰에서 레이가 초록/빨강으로 그려지고, 맞으면 Console에 오브젝트 이름이 찍힌다. 여기서 확인하는 포인트는 두 가지다. 첫째, Renderer를 꺼도 Collider가 있으면 계속 맞는다. 둘째, QueryTriggerInteraction.Ignore 때문에 Trigger Collider는 통과한다. 설정 한 줄이 실제 쿼리 결과를 완전히 바꾼다.
엔진 관점에서의 내부 동작
C#에서 Physics.Raycast를 호출하면 UnityEngine.Physics의 정적 메서드가 실행되고, 내부적으로는 바인딩을 통해 네이티브 엔진 함수로 넘어간다. 이 시점부터는 C# 코드가 아니라 C++ Physics 모듈(PhysX 기반)의 ‘Scene Query’가 일을 한다. C#은 인자(원점/방향/거리/마스크/트리거 옵션)를 네이티브가 이해하는 형태로 복사해 전달하고, 결과 구조체(RaycastHit)에 값을 채워 다시 돌려받는다.
PlayerLoop 관점에서 Raycast는 ‘물리 시뮬레이션 단계’에 속하지 않는다. Update에서 Raycast를 호출해도 즉시 쿼리가 수행된다. 다만 쿼리가 참조하는 Physics 월드의 상태는 마지막으로 동기화된 Collider/Transform 상태에 의존한다. Transform을 바꿨는데 같은 프레임에 Raycast가 이전 위치를 기준으로 쏘는 것처럼 느껴질 때가 있는데, 그때는 Transform 변경이 Physics에 반영되는 타이밍(Transform->Physics 동기화)과 쿼리 호출 순서를 의심해야 한다.
왜 FixedUpdate와 Update가 분리되었나라는 질문이 여기서 실제 문제로 튀어나온다. Rigidbody가 물리 스텝(FixedUpdate 타이밍)에서 이동하면, Update에서 쏘는 Ray는 ‘렌더링 프레임 기준’이고 물리는 ‘고정 스텝 기준’이라서 미세한 시간차가 생긴다. 고속 이동 물체를 조준할 때 한 프레임 뒤에 맞는 느낌이 드는 이유가 된다.
Raycast 쿼리는 보통 브로드페이즈(Broadphase)에서 후보 Collider를 먼저 걸러낸 뒤, 내로우페이즈(Narrowphase)에서 실제 형태(박스/스피어/캡슐/메시 등)로 교차 테스트를 한다. LayerMask는 브로드페이즈 후보를 줄이는 데 직접적으로 도움이 된다. 반대로 MeshCollider(특히 Convex가 꺼진 경우)는 내로우페이즈 비용이 비싸서, 클릭 판정에 남발하면 프레임당 0.5~3ms 단위로 튈 수 있다(콜라이더 개수/삼각형 수/플랫폼에 따라 편차가 크다).
메모리 관점에서 Physics.Raycast 자체는 기본 오버로드를 쓰면 관리 힙 할당(GC Alloc)이 거의 없다. Ray, RaycastHit는 struct라서 스택/레지스터로 처리된다. 하지만 결과로 받은 hitInfo.collider를 통해 GameObject/Component를 따라가며 문자열을 만들거나(보간 문자열), LINQ로 후처리하면 그때 GC가 터진다. ‘Raycast가 GC를 만든다’는 오해는 대개 후처리 코드에서 발생한다.
또 하나의 함정은 다중 히트 쿼리다. Physics.RaycastAll은 결과 배열을 새로 만들어 반환한다. 이 배열 생성이 매 프레임 반복되면 GC Alloc이 지속적으로 발생한다. 예를 들어 히트가 10개만 나와도 RaycastHit[]가 매 프레임 1개씩 만들어지고, 60fps면 1초에 60개가 쌓인다. 그래서 NonAlloc API가 따로 존재한다. API 설계 의도는 ‘편의(All) vs 제어(NonAlloc)’의 분리다.
1using UnityEngine;
2
3public class PlayerLoopRaycastTiming : MonoBehaviour
4{
5 public Transform mover;
6 public float speed = 5f;
7 public float rayDistance = 5f;
8
9 void FixedUpdate()
10 {
11 if (mover == null) return;
12 mover.position += Vector3.right * speed * Time.fixedDeltaTime;
13 }
14
15 void Update()
16 {
17 if (mover == null) return;
18 Ray r = new Ray(mover.position, Vector3.forward);
19 bool hit = Physics.Raycast(r, rayDistance);
20 Debug.DrawRay(r.origin, r.direction * rayDistance, hit ? Color.green : Color.yellow);
21 }
22}Hierarchy 우클릭 → 3D Object → Cube로 바닥을 만들고, Cube에 BoxCollider가 있는지 확인한다. mover로는 Sphere를 하나 만들어 Transform을 연결한다. Play를 누르면 Sphere가 FixedUpdate에서 일정 간격으로 이동하고, Update에서 쏜 Ray는 프레임마다 그려진다. 프레임레이트가 30fps로 떨어지면 Ray의 시작점이 더 크게 점프하고, 120fps면 더 촘촘하게 움직인다. 물리 스텝과 렌더 스텝이 분리되어 있다는 증거다.
1using UnityEngine;
2
3public class RaycastAllVsNonAlloc : MonoBehaviour
4{
5 public Camera cam;
6 public float maxDistance = 100f;
7 public LayerMask mask = ~0;
8
9 RaycastHit[] hits = new RaycastHit[16];
10
11 void Update()
12 {
13 if (cam == null) cam = Camera.main;
14 Ray ray = cam.ScreenPointToRay(Input.mousePosition);
15
16 // All: 매 호출마다 새 배열이 만들어질 수 있다(상황/버전에 따라 다르지만 GC 위험이 큰 패턴).
17 // var all = Physics.RaycastAll(ray, maxDistance, mask);
18
19 int count = Physics.RaycastNonAlloc(ray, hits, maxDistance, mask, QueryTriggerInteraction.Ignore);
20 if (count > 0)
21 {
22 Debug.Log($"NonAlloc hits={count} first={hits[0].collider.name}");
23 }
24 }
25}Profiler에서 CPU Usage와 GC Alloc을 같이 켠다. Window → Analysis → Profiler 클릭, Play 후 Timeline에서 스파이크 프레임을 찍는다. RaycastAll을 주석 해제하고 매 프레임 호출하면, 씬에 콜라이더가 500개 이상인 상황에서 GC Alloc이 프레임마다 수백 B~수 KB씩 누적되는 경우가 생긴다. NonAlloc은 hits 배열을 재사용하므로 같은 조건에서 GC Alloc이 0B에 붙는지 확인 가능하다.
한 문단 요약: Raycast는 C#에서 즉시 네이티브 Physics 쿼리로 내려가 브로드페이즈(레이어 필터 포함)→내로우페이즈로 후보를 줄여 히트를 만든다. 성능/버그의 대부분은 ‘레이어/트리거/동기화 타이밍/All vs NonAlloc’에서 터진다.
실습하기
1단계: 프로젝트 설정과 레이어 준비
Unity Hub → New Project에서 3D(Core) 템플릿을 선택한다. 버전은 2022.3 LTS 또는 2023.2+면 충분하다. 프로젝트 생성 후 Edit → Project Settings → Physics로 들어가 Queries Hit Triggers 옵션이 켜져 있는지 확인한다. 이 옵션이 켜져 있으면 Raycast가 Trigger도 맞을 수 있다(단, QueryTriggerInteraction 설정이 우선한다).
레이어를 만든다. Edit → Project Settings → Tags and Layers → Layers에서 User Layer 8에 "Ground", 9에 "Interactable"을 입력한다. 이 레이어가 LayerMask 비트로 변환되어 네이티브 쿼리 필터로 들어간다. 레이어 이름은 C#에서 LayerMask.NameToLayer로 숫자로 바뀐다.
1using UnityEngine;
2
3public class LayerMaskSetupCheck : MonoBehaviour
4{
5 void Start()
6 {
7 int ground = LayerMask.NameToLayer("Ground");
8 int interactable = LayerMask.NameToLayer("Interactable");
9
10 Debug.Log($"Ground layer index={ground}");
11 Debug.Log($"Interactable layer index={interactable}");
12
13 int mask = (1 << ground) | (1 << interactable);
14 Debug.Log($"Mask bits={mask} (binary={System.Convert.ToString(mask, 2)})");
15 }
16}Play를 누르면 Console에 레이어 인덱스가 찍힌다. -1이 찍히면 레이어 이름 오타다. 레이어가 -1이면 (1 << -1) 같은 계산이 터지거나, 마스크가 엉뚱해져서 Raycast가 ‘가끔 안 맞는’ 느낌을 만든다.
2단계: 씬 구성(콜라이더/트리거/레이어)으로 차이를 눈으로 만들기
Hierarchy 우클릭 → 3D Object → Plane을 만들고 이름을 Ground로 바꾼다. Inspector에서 Layer를 Ground로 변경한다. 그 다음 Hierarchy 우클릭 → 3D Object → Cube를 만들고 이름을 Wall로 바꾼 뒤 Z=5 정도로 배치한다. Wall의 Layer는 Default로 둔다.
Interactable 대상도 만든다. Hierarchy 우클릭 → 3D Object → Sphere 생성 후 이름을 Target으로 변경, Z=3에 둔다. Inspector에서 Layer를 Interactable로 바꾸고 SphereCollider의 Is Trigger를 체크한다. 같은 위치에 Trigger와 Non-Trigger를 섞으면 QueryTriggerInteraction의 효과가 바로 드러난다.
1using UnityEngine;
2
3public class RaycastLayerAndTriggerDemo : MonoBehaviour
4{
5 public Camera cam;
6 public float maxDistance = 30f;
7 public LayerMask interactableMask;
8
9 void Reset()
10 {
11 interactableMask = LayerMask.GetMask("Interactable");
12 }
13
14 void Update()
15 {
16 if (cam == null) cam = Camera.main;
17 Ray ray = cam.ScreenPointToRay(Input.mousePosition);
18
19 bool hitTriggerIgnored = Physics.Raycast(ray, out RaycastHit a, maxDistance, interactableMask, QueryTriggerInteraction.Ignore);
20 bool hitTriggerCollide = Physics.Raycast(ray, out RaycastHit b, maxDistance, interactableMask, QueryTriggerInteraction.Collide);
21
22 if (hitTriggerIgnored) Debug.Log($"Ignore hit: {a.collider.name}");
23 if (hitTriggerCollide) Debug.Log($"Collide hit: {b.collider.name}");
24
25 Debug.DrawRay(ray.origin, ray.direction * maxDistance, Color.cyan);
26 }
27}Inspector에서 RaycastLayerAndTriggerDemo의 Interactable Mask가 Interactable만 체크되어 있는지 확인한다. Play 후 Target(Trigger Sphere)에 마우스를 올리면 Collide hit만 찍히고 Ignore hit는 안 찍힌다. Trigger가 ‘충돌은 안 하지만 쿼리는 맞을 수 있다’는 사실이 로그로 고정된다.
3단계: 클릭으로 선택하고, 왜 선택되는지 디버그 정보로 증명하기
Hierarchy 우클릭 → Create Empty로 Raycaster 오브젝트를 만들고, 방금 스크립트를 붙인다. Main Camera가 없으면 Hierarchy 우클릭 → Camera로 생성하고 Tag를 MainCamera로 둔다. Camera 위치는 (0,2,-10), Rotation은 (10,0,0) 정도로 맞춘다.
Inspector에서 maxDistance를 100으로 올리고, Interactable Mask는 Interactable만 체크한다. Play 중 클릭하면 선택된 오브젝트 이름과 레이어가 Console에 찍힌다. 클릭이 Wall에 막히지 않는 이유는 마스크가 Wall 레이어를 후보에서 제외했기 때문이다. ‘벽이 있는데도 선택된다’가 버그가 아니라 설계라는 점이 여기서 드러난다.
1using UnityEngine;
2
3public class ClickSelectWithReason : MonoBehaviour
4{
5 public Camera cam;
6 public LayerMask mask;
7 public float maxDistance = 100f;
8
9 void Reset()
10 {
11 mask = LayerMask.GetMask("Interactable");
12 }
13
14 void Update()
15 {
16 if (cam == null) cam = Camera.main;
17 if (!Input.GetMouseButtonDown(0)) return;
18
19 Ray ray = cam.ScreenPointToRay(Input.mousePosition);
20 if (Physics.Raycast(ray, out RaycastHit hit, maxDistance, mask, QueryTriggerInteraction.Collide))
21 {
22 Debug.Log($"Selected={hit.collider.name} layer={LayerMask.LayerToName(hit.collider.gameObject.layer)} distance={hit.distance:F2}");
23 }
24 else
25 {
26 Debug.Log("Selected=none (mask filtered or no collider in ray path)");
27 }
28 }
29}처음에 나도 ‘레이어 마스크는 충돌 설정이니까 벽이 있으면 막히겠지’라고 착각해서 2시간을 태웠다. Profiler가 아니라 로그로 증명하는 게 빠르다. Selected=none이 뜨면 레이어가 틀렸거나, Collider가 없거나, 카메라가 다른 곳을 보고 있거나, 트리거 설정이 맞지 않는 것이다.
심화 활용
패턴 1: RaycastNonAlloc + 거리 우선 정렬 없이 ‘첫 히트만’ 쓰기
실무 UI/상호작용은 보통 ‘가장 가까운 것 하나’만 필요하다. RaycastAll로 전부 받아 정렬하면 관리 힙 할당과 정렬 비용이 같이 붙는다. NonAlloc으로 버퍼를 재사용하고, 최소 거리만 선형 탐색하면 예측 가능한 비용으로 고정된다.
1using UnityEngine;
2
3public class ClosestHitNonAlloc : MonoBehaviour
4{
5 public Camera cam;
6 public float maxDistance = 50f;
7 public LayerMask mask;
8
9 RaycastHit[] buffer = new RaycastHit[32];
10
11 void Reset()
12 {
13 mask = LayerMask.GetMask("Interactable");
14 }
15
16 void Update()
17 {
18 if (cam == null) cam = Camera.main;
19 Ray ray = cam.ScreenPointToRay(Input.mousePosition);
20
21 int count = Physics.RaycastNonAlloc(ray, buffer, maxDistance, mask, QueryTriggerInteraction.Collide);
22 if (count <= 0) return;
23
24 int best = 0;
25 float bestDist = buffer[0].distance;
26 for (int i = 1; i < count; i++)
27 {
28 if (buffer[i].distance < bestDist)
29 {
30 bestDist = buffer[i].distance;
31 best = i;
32 }
33 }
34
35 Debug.Log($"Closest={buffer[best].collider.name} dist={bestDist:F2} hits={count}");
36 }
37}Play 후 마우스를 여러 Collider가 겹치는 지점으로 옮기면 hits 값이 늘어난다. 그 상태에서도 로그는 가장 가까운 하나만 찍힌다. 이 방식은 프레임당 Raycast 호출 횟수가 많을 때(예: 적 50명이 시야 체크) ‘정렬 비용’이 사라지는 게 체감된다.
패턴 2: PhysicsScene을 명시해 멀티 씬/프리뷰 월드에서 쿼리 분리하기
Scene이 여러 개 로드되거나(어드레서블, additive), 에디터 툴에서 프리뷰용 물리 월드를 따로 쓰는 경우가 있다. 이때 정적 Physics.Raycast는 기본 PhysicsScene을 대상으로 한다. 의도한 씬이 아니라 다른 씬의 Collider를 맞는 일이 실제로 생긴다. PhysicsScene을 명시하면 쿼리 범위를 고정할 수 있다.
1using UnityEngine;
2using UnityEngine.SceneManagement;
3
4public class ExplicitPhysicsSceneRaycast : MonoBehaviour
5{
6 public Camera cam;
7 public float maxDistance = 100f;
8 public LayerMask mask = ~0;
9
10 PhysicsScene scenePhysics;
11
12 void Start()
13 {
14 Scene active = SceneManager.GetActiveScene();
15 scenePhysics = active.GetPhysicsScene();
16 Debug.Log($"PhysicsScene valid={scenePhysics.IsValid()}");
17 }
18
19 void Update()
20 {
21 if (cam == null) cam = Camera.main;
22 if (!Input.GetMouseButtonDown(0)) return;
23
24 Ray ray = cam.ScreenPointToRay(Input.mousePosition);
25 if (scenePhysics.Raycast(ray.origin, ray.direction, out RaycastHit hit, maxDistance, mask, QueryTriggerInteraction.Ignore))
26 {
27 Debug.Log($"ActiveScene hit={hit.collider.name}");
28 }
29 }
30}처음에 나도 Additive로 로드한 미니게임 씬에서 클릭이 메인 씬 오브젝트를 맞는 현상을 겪었다. Console에는 분명 다른 씬 오브젝트 이름이 찍혔고, 레이어도 동일해서 더 헷갈렸다. 3시간 삽질 끝에 원인이 ‘정적 Physics가 기본 PhysicsScene만 본다’는 점이라는 걸 찾았다. PhysicsScene을 명시하니 바로 재현이 사라졌다.
Profiler에서 Physics.Raycast가 0.02ms라서 안심했다가, 실제 프레임 드랍은 Debug.Log 보간 문자열과 RaycastAll 배열 할당에서 터진 적도 있다. Timeline에서 GC.Collect가 튀는 프레임을 클릭하면, 스택에 RaycastAll과 string.Format 계열이 같이 보인다. Raycast 자체보다 ‘주변 코드’가 더 위험하다는 사례다.
자주 하는 실수
실수 1: LayerMask를 int 레이어 인덱스로 착각
증상: 특정 오브젝트만 맞아야 하는데 전부 맞거나, 아무것도 안 맞는다. Console에 Selected=none이 계속 찍힌다.
원인: mask에 LayerMask.NameToLayer("Interactable") 값을 그대로 넣는다. Raycast의 layerMask 인자는 ‘레이어 번호’가 아니라 ‘비트마스크’다. 9번 레이어를 넣으면 (1<<9)이 아니라 9로 필터링되어 엉뚱한 비트가 켜진 상태가 된다.
해결: LayerMask.GetMask("Interactable") 또는 (1 << LayerMask.NameToLayer("Interactable"))을 쓴다. Inspector에서는 LayerMask 필드에서 체크박스로 선택한다. 숫자를 직접 입력하는 방식은 디버깅 난이도가 급상승한다.
실수 2: Trigger가 안 맞는다고 Collider가 고장났다고 판단
증상: Trigger Collider에 Ray를 쏘면 절대 맞지 않는다. 반대로 어떤 프로젝트에서는 Trigger가 너무 잘 맞아서 벽 뒤 오브젝트까지 선택된다.
원인: QueryTriggerInteraction 기본값과 Project Settings → Physics의 Queries Hit Triggers 설정을 섞어 이해한다. 코드에서 QueryTriggerInteraction.Ignore로 호출하면 프로젝트 설정과 무관하게 트리거가 제외된다.
해결: 호출부에서 QueryTriggerInteraction를 명시한다. 클릭 선택은 보통 Collide, 총알/시야는 Ignore를 많이 쓴다. Trigger를 ‘센서’로 쓰는지 ‘선택 대상’으로 쓰는지부터 정하고 옵션을 고정한다.
실수 3: Transform을 움직였는데 Raycast가 예전 위치 기준처럼 느껴짐
증상: 빠르게 움직이는 오브젝트에서 Ray를 쏘면 한 프레임 늦게 따라오는 느낌이 든다. 특히 Rigidbody 이동과 조준선이 어긋난다.
원인: 물리 이동은 FixedUpdate 스텝, 입력/렌더는 Update 스텝이다. Rigidbody를 MovePosition/물리력으로 움직이면 Physics 월드 업데이트 타이밍과 렌더 프레임이 어긋나며, 그 사이에 쿼리를 실행하면 ‘예전 상태’처럼 보인다.
해결: 조준/판정 기준을 통일한다. 물리 기반 판정이면 FixedUpdate에서 쿼리하고, 렌더 기반 조준선이면 Interpolation 설정과 카메라 업데이트 순서를 맞춘다. 재현이 애매하면 FixedUpdate/Update에서 각각 Debug.DrawRay 색을 다르게 그려 차이를 눈으로 만든다.
실수 4: Collider가 없는데 MeshRenderer만 보고 맞을 거라 기대
증상: 화면에 보이는 오브젝트가 클릭이 안 된다. 반대로 보이지 않는 오브젝트가 클릭된다.
원인: Raycast는 Renderer를 보지 않고 Collider만 본다. 반투명/비활성 렌더링과 무관하게 Collider가 남아 있으면 맞는다. 반대로 모델 임포트 후 Collider를 추가하지 않으면 절대 맞지 않는다.
해결: Inspector에서 해당 오브젝트에 Collider 컴포넌트가 있는지 확인한다. Hierarchy에서 오브젝트 선택 → Inspector 검색창에 "Collider"를 입력해 빠르게 확인한다. 클릭 판정이 필요한 대상은 레이어와 Collider를 세트로 관리한다.
실수 5: RaycastAll을 Update에서 매 프레임 호출하고 GC 스파이크를 못 찾음
증상: 1~2초에 한 번씩 프레임이 뚝 떨어진다. Profiler에서 GC.Collect가 튄다. CPU Usage에는 Physics가 아니라 Scripts가 길게 찍힌다.
원인: RaycastAll이 반환하는 배열, 그리고 결과를 문자열 보간/리스트 변환/LINQ로 처리하면서 관리 힙 할당이 누적된다. 초당 60회 * 배열 1개만 생성해도 누적이 크고, 히트 수가 늘면 더 커진다.
해결: RaycastNonAlloc으로 버퍼를 재사용한다. 로그는 Development Build에서만 켜거나, 조건부로 줄인다. Profiler에서 GC Alloc 컬럼을 켜고 ‘어느 줄에서 0B가 64B로 바뀌는지’를 먼저 찾는다.
1using UnityEngine;
2
3public class MaskBugExample : MonoBehaviour
4{
5 public Camera cam;
6 public float maxDistance = 50f;
7
8 void Update()
9 {
10 if (cam == null) cam = Camera.main;
11 if (!Input.GetMouseButtonDown(0)) return;
12
13 int wrong = LayerMask.NameToLayer("Interactable"); // 레이어 번호
14 int right = 1 << LayerMask.NameToLayer("Interactable"); // 비트마스크
15
16 Ray ray = cam.ScreenPointToRay(Input.mousePosition);
17
18 bool a = Physics.Raycast(ray, maxDistance, wrong);
19 bool b = Physics.Raycast(ray, maxDistance, right);
20
21 Debug.Log($"wrongMaskHit={a} rightMaskHit={b} wrong={wrong} right={right}");
22 }
23}이 코드는 클릭할 때마다 wrongMaskHit와 rightMaskHit가 어떻게 달라지는지 찍는다. 씬에 Interactable 레이어 오브젝트가 있어도 wrongMaskHit가 false로 나올 수 있다. 숫자 하나의 의미(레이어 인덱스 vs 비트마스크)가 엔진 쿼리 결과를 갈라버리는 장면을 로그로 확인한다.
성능 최적화 체크리스트
- Raycast 호출부에 LayerMask를 숫자로 직접 넣지 않고 LayerMask.GetMask 또는 (1<<layer)로 만든다
- Update에서 RaycastAll을 반복 호출하는 코드를 검색하고, 필요하면 RaycastNonAlloc로 교체한다
- Profiler(Window → Analysis → Profiler)에서 CPU Usage + GC Alloc을 동시에 켜고 스파이크 프레임의 콜스택을 확인한다
- Raycast 결과 후처리에서 문자열 보간($"...")과 Debug.Log가 매 프레임 실행되는지 확인한다
- Trigger를 맞출지 무시할지 QueryTriggerInteraction를 호출부에서 명시한다
- Project Settings → Physics의 Queries Hit Triggers/Queries Hit Backfaces 설정이 의도와 같은지 확인한다
- MeshCollider를 클릭 판정에 쓰는 경우, Convex/요리된 메시(cooking) 비용과 대체 콜라이더(Box/Capsule) 가능성을 검토한다
- 고속 이동 물체 조준/피격 판정은 Update vs FixedUpdate 중 기준을 하나로 고정하고, Rigidbody Interpolation을 함께 점검한다
- 레이어 설계를 ‘렌더링 분류’와 ‘물리 쿼리 분류’로 분리하고, Raycast 마스크는 최소 레이어만 포함한다
- RaycastHit에서 collider/gameObject 접근이 많은 구간은 캐싱(예: hit.colliderInstanceID 기반 매핑) 또는 처리량 제한을 검토한다
- 멀티 씬(Additive) 환경이면 정적 Physics.Raycast 대신 Scene.GetPhysicsScene().Raycast로 쿼리 범위를 고정한다
- 프레임당 Raycast 호출 예산을 정한다(예: 모바일 60fps 기준 200~500회 내에서 시작) 그리고 실제 Profiler 수치로 조정한다
자주 묻는 질문
Raycast가 Update에서 호출되면 물리 시뮬레이션이 끝난 뒤에 실행되는가?
Raycast는 ‘다음 FixedUpdate까지 기다렸다가 처리’되는 비동기 작업이 아니라, 호출한 그 자리에서 즉시 Physics 월드에 질의를 던지는 동기 호출이다. 다만 질의가 참조하는 월드 상태가 언제 갱신됐는지가 중요하다. Rigidbody 이동이 FixedUpdate에서 일어나면, Update에서 쿼리할 때는 ‘최근 물리 스텝 결과’ 기준으로 맞는다. 그래서 입력(Update) 기반 조준과 물리(FixedUpdate) 기반 이동이 섞이면 1프레임 정도 어긋난 느낌이 생긴다. 다음 학습 키워드는 PlayerLoop, Fixed Timestep, Rigidbody Interpolation이다.
Collider가 없는데도 Raycast가 맞는 것처럼 보이는 경우가 있다. 왜 그런가?
대부분은 ‘다른 오브젝트의 Collider를 맞고 있는데, 시각적으로는 현재 오브젝트를 맞은 것처럼 착각’하는 케이스다. 예를 들어 보이지 않는 프록시 콜라이더(투명 벽), 이전에 배치한 테스트용 큐브가 레이어 마스크에 포함되어 있으면 Ray가 거기를 먼저 맞는다. RaycastHit.distance와 hit.collider.name을 로그로 찍으면 바로 드러난다. 또 MeshRenderer를 끄거나 머티리얼 알파를 낮춰도 Collider는 그대로 남는다. 다음 학습 키워드는 Debug.DrawRay, RaycastHit.distance, 레이어/마스크 디버깅이다.
LayerMask를 Everything으로 두면 왜 씬이 커질수록 느려지는가?
Raycast는 모든 Collider를 무식하게 선형 스캔하지는 않지만, 브로드페이즈에서 후보를 줄이는 단계가 있어도 ‘후보가 많으면’ 내로우페이즈 교차 테스트가 늘어난다. Everything은 후보를 줄이는 가장 강력한 필터(레이어)를 사실상 포기하는 선택이다. 특히 MeshCollider가 많거나, 트리거/센서가 많거나, 작은 콜라이더가 빽빽한 씬에서 비용이 튄다. 레이어를 분리하면 브로드페이즈에서 비트마스크로 후보를 크게 잘라낼 수 있다. 다음 학습 키워드는 Broadphase/Narrowphase, MeshCollider 비용, 레이어 설계다.
Physics.RaycastAll과 RaycastNonAlloc의 차이는 단순히 GC 여부인가?
가장 큰 차이는 ‘결과 컨테이너를 누가 소유하나’다. RaycastAll은 호출자가 결과 배열을 받는 구조라, 엔진이 결과 수만큼 담을 새 배열을 준비해야 한다. 이 과정이 관리 힙 할당으로 이어지기 쉬워서 프레임 반복 호출에 취약하다. RaycastNonAlloc은 호출자가 버퍼를 미리 제공하고, 엔진은 그 버퍼 크기 안에서만 채운다. 대신 히트가 버퍼보다 많으면 잘린다. 실무에서는 상호작용/시야 체크처럼 반복되는 쿼리는 NonAlloc, 디버그/툴/일회성은 All을 쓰는 식으로 분리한다. 다음 학습 키워드는 GC Alloc 패턴, 버퍼 사이징, 히트 정렬 전략이다.
Trigger를 무시했는데도 Trigger가 맞는 로그가 찍힌다. 무엇을 의심해야 하나?
첫째, 호출 오버로드를 확인한다. Physics.Raycast(ray, out hit, dist, mask)처럼 QueryTriggerInteraction를 생략하면 기본 정책이 적용되고, 프로젝트 설정(Queries Hit Triggers)과 결합되어 기대와 달라질 수 있다. 둘째, 맞은 Collider가 진짜 Trigger인지 확인한다. 같은 오브젝트에 Collider가 2개 붙어 있고 하나만 Trigger인 경우도 흔하다. hit.collider.isTrigger를 로그로 찍으면 확정된다. 셋째, 다른 스크립트에서 별도의 Raycast가 동시에 찍히고 있을 수 있다. 다음 학습 키워드는 QueryTriggerInteraction, Physics 설정, 다중 콜라이더 구성이다.
RaycastHit.collider나 hit.transform 접근이 느리다는 말이 있는데, 실제로 어떤 비용인가?
RaycastHit 자체는 값 타입이라 결과를 받는 것만으로는 큰 비용이 없다. 비용이 생기는 지점은 hit.collider 같은 프로퍼티로 UnityEngine.Object 래퍼를 만질 때다. 네이티브(C++)에 존재하는 오브젝트를 C# 객체처럼 다루기 위해 내부적으로 인스턴스 ID 매핑과 래퍼 조회가 일어난다. 보통은 미미하지만, 프레임당 수천 히트를 처리하면서 매 히트마다 name 문자열을 만들거나 GetComponent를 연쇄 호출하면 비용이 확 커진다. 다음 학습 키워드는 UnityEngine.Object 마샬링, InstanceID, 문자열/컴포넌트 접근 비용이다.
레이가 ‘벽을 무시하고 뒤의 오브젝트를 선택’하는 현상을 어떻게 막나?
대부분은 마스크 설계 문제다. 상호작용 대상만 포함한 마스크로 쏘면, 벽 레이어가 후보에서 제외되므로 벽이 있어도 뒤가 선택된다. 해결은 두 단계다. 1) 벽을 포함한 마스크로 먼저 Raycast해서 ‘가장 앞의 히트’를 구한다. 2) 그 히트가 Interactable인지 검사해 선택한다. 또는 RaycastNonAlloc으로 여러 히트를 받은 뒤 distance 기준으로 가장 가까운 것부터 레이어/태그 조건을 적용한다. 이 방식은 ‘가림(occlusion)’ 개념을 쿼리로 구현하는 것이다. 다음 학습 키워드는 occlusion query 패턴, 다중 히트 처리, 레이어 우선순위다.