4. Raycast 충돌 지점이 안 잡힐 때: 레이어 마스크·Physics 설정 디버깅
Unity Raycast가 맞아야 할 Collider를 못 맞출 때 레이어 마스크, Physics 설정, QueryTriggerInteraction, FixedUpdate 타이밍을 엔진 관점에서 추적하는 디버깅 절차를 다룬다. (C#) 154자 내외.
Raycast 충돌 지점이 안 잡힐 때: 레이어 마스크·Physics 설정 디버깅
게임 만들다 겪는 사고 중 하나가 클릭/조준이 갑자기 먹통이 되는 순간이다. 화면 중앙에 크로스헤어를 두고 Raycast를 쏘는데, 분명히 벽을 보고 있어도 hit.point가 (0,0,0)처럼 나오거나 아예 false가 떨어진다. 로그를 찍어도 ‘Raycast miss’만 반복되고, 콜라이더도 붙어 있는데 왜 못 맞추는지 감이 안 온다. 이런 문제는 코드 오타보다 ‘레이어 필터링’과 ‘Physics 전역 설정’에서 더 자주 터진다. 엔진이 레이를 쿼리할 때 어떤 필터를 적용하는지부터 추적해야 빨리 끝난다.
핵심 개념
Raycast는 물리 시뮬레이션(힘, 속도)을 ‘진행’시키는 기능이 아니라, 현재 물리 월드에 등록된 콜라이더를 대상으로 ‘쿼리’하는 기능이다. 그래서 같은 프레임이라도 어떤 시점에 쿼리하느냐(Update/FixedUpdate), 어떤 필터를 주느냐(레이어 마스크/Trigger 포함 여부)에 따라 결과가 달라진다. 충돌 지점이 안 잡힌다는 말은 대부분 “쿼리 대상에서 제외됐다”는 뜻이다.
레이어 마스크는 단순한 태그가 아니라 비트마스크(32비트)다. Physics.Raycast는 이 비트마스크를 네이티브 쿼리 함수로 넘기고, 엔진은 콜라이더의 레이어 비트와 AND 연산으로 후보를 거른다. 레이어가 하나만 틀려도 ‘완벽하게’ 아무것도 맞지 않는다. 디버깅할 때는 마스크 값(정수)까지 로그로 찍어야 한다.
Project Settings의 Physics에는 두 가지가 섞여 있다. 1) Layer Collision Matrix: 실제 충돌(접촉/반발) 여부 2) Queries Hit Triggers / Queries Hit Backfaces: 레이/스피어캐스트 같은 쿼리의 전역 기본값. 많은 사람이 매트릭스만 보고 ‘충돌 가능’하다고 착각한다. Raycast는 매트릭스와 무관하게도 실패할 수 있고, 반대로 매트릭스가 막혀도 Raycast는 맞을 수 있다(쿼리 필터가 다르기 때문).
Trigger는 ‘접촉 해결’만 안 할 뿐, 콜라이더로서 월드에 존재한다. Raycast가 Trigger를 맞출지는 QueryTriggerInteraction와 Physics.queriesHitTriggers 전역 설정이 결정한다. 초보 입장에서는 “콜라이더가 있는데 왜 안 맞지?”가 여기서 시작된다.
핵심 용어를 현장 맥락으로 정의한다. LayerMask: 레이/충돌 후보를 비트로 필터링하는 값. QueryTriggerInteraction: Trigger를 맞출지 말지 호출 단위로 결정. Physics.queriesHitTriggers: 호출에서 UseGlobal을 썼을 때 적용되는 전역 기본값. DefaultRaycastLayers: 유니티가 ‘레이캐스트에 포함’시키는 레이어 집합(기본은 Ignore Raycast 제외). 예를 들어 DefaultRaycastLayers는 보통 -5(= 0xFFFFFFFB)로 찍히며, 이는 32개 레이어 중 2번(= Ignore Raycast)만 빠진 비트마스크라는 뜻이다. Layer Collision Matrix: 리지드바디가 실제로 서로 충돌 해결을 할지 결정.
1using UnityEngine;
2
3public class BasicRaycastExample : MonoBehaviour
4{
5 [SerializeField] private Camera cam;
6 [SerializeField] private LayerMask mask = ~0; // Everything
7 [SerializeField] private float maxDistance = 100f;
8
9 private void Update()
10 {
11 if (cam == null) cam = Camera.main;
12
13 Ray ray = cam.ScreenPointToRay(Input.mousePosition);
14 bool hit = Physics.Raycast(ray, out RaycastHit hitInfo, maxDistance, mask, QueryTriggerInteraction.UseGlobal);
15
16 Debug.Log($"hit={hit}, maskInt={(int)mask}, origin={ray.origin}, dir={ray.direction}");
17 if (hit) Debug.Log($"name={hitInfo.collider.name}, point={hitInfo.point}, layer={hitInfo.collider.gameObject.layer}");
18 }
19}이 코드를 실행하면 콘솔에 maskInt가 찍힌다. 여기서 maskInt가 의도한 값인지부터 확인한다. 예를 들어 Obstacle 레이어(예: User Layer 8)만 맞추려는 상황이면 maskInt는 1<<8인 256이 찍혀야 한다. 그런데 0이 찍히면 엔진 입장에서는 “후보 레이어가 하나도 없다”가 된다. Raycast는 네이티브 쿼리로 넘어가며, 마스크가 0이면 브로드페이즈 후보 생성 단계에서 바로 종료되는 경우가 많다.
엔진 관점에서의 내부 동작
C#의 Physics.Raycast는 실제 계산을 C#에서 하지 않는다. UnityEngine.Physics는 C# 래퍼이고, 내부적으로는 icall/내부 호출로 네이티브 엔진(C++)의 물리 쿼리 함수로 넘어간다. 이때 전달되는 값은 대략 Ray(origin, direction), maxDistance, layerMask(int), queryTriggerInteraction(enum), 그리고 결과를 담을 RaycastHit 구조체다.
RaycastHit은 값 타입(struct)이라 C# 힙 할당 없이 스택에 잡히는 형태로 쓰기 쉽다. 하지만 hitInfo.collider 같은 프로퍼티를 접근하는 순간, 네이티브의 콜라이더 핸들을 C# 오브젝트로 래핑해서 넘겨야 한다. 이 래핑은 GC Alloc을 직접 만들지 않는 경우가 많지만(엔진이 캐시된 UnityEngine.Object를 재사용), 접근 빈도가 높으면 메인 스레드에서 마샬링/핸들 검증 비용이 쌓인다. 그래서 “맞았는지 여부만” 필요한 프레임에는 hitInfo.collider를 매번 만지지 않는 편이 낫다.
Player Loop 관점에서 Raycast는 스크립트가 호출하는 즉시 실행되는 동기 쿼리다. Update에서 호출하면 그 프레임의 Update 단계에서 물리 월드 스냅샷을 기준으로 쿼리한다. FixedUpdate는 물리 시뮬레이션 스텝 직후에 호출되므로, 리지드바디가 움직이는 게임에서 ‘보이는 위치’와 ‘물리 월드의 위치’ 차이를 줄이는 데 유리하다. Update에서 Transform.position을 바꿔놓고 같은 프레임에 Raycast를 쏘면, 물리 엔진이 아직 그 이동을 반영하지 않았을 수 있다(특히 Rigidbody가 있을 때).
왜 이런 분리가 생겼나를 엔진 설계 관점에서 보면, 물리 시뮬레이션은 고정 시간 스텝(예: 0.02s)으로 안정적으로 돌려야 한다. 프레임레이트는 30~240fps로 흔들리니 Update에 물리를 묶으면 결과가 달라진다. Unity는 FixedUpdate 사이클에서 물리를 진행시키고, Update는 렌더링/입력 중심으로 두는 구조다. Raycast 같은 쿼리는 ‘물리 월드’를 읽는 작업이므로, 어떤 월드를 읽고 있는지(마지막 시뮬레이션 결과인지, 이동 반영 전인지)를 의식해야 한다.
레이어 마스크 필터링은 네이티브 쿼리에서 가장 먼저 적용되는 값 중 하나다. 브로드페이즈(대략적인 공간 분할)에서 AABB 후보를 찾을 때부터 레이어가 맞지 않으면 후보로 넣지 않는다. 그래서 디버깅이 쉬운 편이다. 반대로 Trigger/Backface 같은 옵션은 후보가 잡힌 뒤 세부 교차 테스트에서 걸러질 수 있다. ‘후보가 애초에 없었다’와 ‘후보는 있었는데 최종 필터에서 버려졌다’를 구분해야 한다.
처음에 나도 이게 왜 이렇게 동작하는지 몰랐다. Layer Collision Matrix에서 두 레이어 충돌이 체크돼 있으니 Raycast도 맞을 거라고 믿고 2시간을 태웠다. 콘솔에는 계속 hit=false. 결국 Physics 설정에서 Queries Hit Triggers가 꺼져 있었고, 내가 맞추려던 대상은 전부 isTrigger=true였다. 매트릭스는 접촉 해결용이고, 쿼리 옵션은 별도라는 걸 그때 몸으로 배웠다.
1using UnityEngine;
2
3public class PlayerLoopRaycastTiming : MonoBehaviour
4{
5 [SerializeField] private Transform target;
6 [SerializeField] private LayerMask mask = ~0;
7
8 private void FixedUpdate()
9 {
10 if (target == null) return;
11 var origin = transform.position;
12 var dir = (target.position - origin).normalized;
13 var hit = Physics.Raycast(origin, dir, out var info, 50f, mask);
14 Debug.Log($"[FixedUpdate] hit={hit} targetPos={target.position} hitPoint={(hit ? info.point.ToString() : "-")}");
15 }
16
17 private void Update()
18 {
19 if (target == null) return;
20 var origin = transform.position;
21 var dir = (target.position - origin).normalized;
22 var hit = Physics.Raycast(origin, dir, out var info, 50f, mask);
23 Debug.Log($"[Update] hit={hit} targetPos={target.position} hitPoint={(hit ? info.point.ToString() : "-")}");
24 }
25}이 코드를 실행하면 같은 프레임에서도 FixedUpdate 로그와 Update 로그가 섞여 나온다. Rigidbody로 움직이는 타겟을 두면, Update에서 본 target.position과 물리 월드의 콜라이더 위치가 어긋나는 순간이 보인다. 이 어긋남이 생기면 레이가 ‘보이는 오브젝트’를 향해 가는데 물리 콜라이더는 다른 위치에 있어 miss가 난다. Scene 뷰에서 Gizmos를 켜고 콜라이더를 표시하면 더 명확해진다.
1using UnityEngine;
2
3public class RaycastFilterDump : MonoBehaviour
4{
5 [SerializeField] private LayerMask mask;
6 [SerializeField] private bool includeTriggers = false;
7
8 private void Start()
9 {
10 Debug.Log($"Physics.queriesHitTriggers={Physics.queriesHitTriggers}");
11 Debug.Log($"Physics.queriesHitBackfaces={Physics.queriesHitBackfaces}");
12 Debug.Log($"DefaultRaycastLayers={Physics.DefaultRaycastLayers}");
13 Debug.Log($"maskInt={(int)mask} includeTriggers={includeTriggers}");
14 }
15
16 private void Update()
17 {
18 var qti = includeTriggers ? QueryTriggerInteraction.Collide : QueryTriggerInteraction.Ignore;
19 var ray = new Ray(transform.position + Vector3.up, transform.forward);
20 var hit = Physics.Raycast(ray, out var info, 20f, mask, qti);
21 Debug.DrawRay(ray.origin, ray.direction * 20f, hit ? Color.green : Color.red);
22 if (hit) Debug.Log($"HIT {info.collider.name} isTrigger={info.collider.isTrigger} layer={info.collider.gameObject.layer}");
23 }
24}이 코드는 전역 설정과 호출 단위 옵션을 동시에 덤프한다. 플레이하면 씬에 빨간/초록 Debug.DrawRay가 보이고, 맞는 순간 collider.isTrigger가 콘솔에 찍힌다. Trigger를 무시하고 있는데 Trigger만 있는 씬이라면 100% miss가 난다. 반대로 전역 Physics.queriesHitTriggers가 false여도, QueryTriggerInteraction.Collide를 넘기면 맞는다. 엔진은 UseGlobal일 때만 전역 값을 읽는다.
실습하기
1단계: 프로젝트 설정(Physics 필터가 보이는 상태 만들기)
Unity 2022.3 LTS 또는 2023.2 이상, 템플릿은 3D(Core)로 생성한다. 씬은 SampleScene 그대로 써도 된다. Scene 뷰 오른쪽 상단 Gizmos 버튼을 켜고, 드롭다운에서 Colliders 표시를 켠다. 레이가 맞는지보다 ‘콜라이더가 어디에 존재하는지’를 먼저 눈으로 확인하는 흐름이 빨라진다.
Project Settings → Physics로 이동한다. 여기서 Layer Collision Matrix를 캡처해두고(스크린샷), Queries Hit Triggers, Queries Hit Backfaces 체크 상태를 기록한다. 실습 중에는 이 전역값을 일부러 토글해 miss를 재현할 거라서, 원래 상태로 되돌릴 기준이 필요하다.
2단계: 씬 구성(레이어/트리거/무시 레이어를 일부러 섞기)
Hierarchy 우클릭 → 3D Object → Plane 생성, 이름을 Ground로 바꾼다. Hierarchy 우클릭 → 3D Object → Cube 생성, 이름을 Wall로 바꾸고 Position을 (0, 0.5, 5)로 둔다. Cube에는 기본 BoxCollider가 붙어 있다. 이때 Wall의 Layer를 새 레이어 Obstacle로 만든 뒤 할당한다(Inspector 상단 Layer 드롭다운 → Add Layer… → User Layer 8에 Obstacle 입력 → 다시 Wall 선택 → Layer=Obstacle).
Hierarchy 우클릭 → 3D Object → Sphere 생성, 이름을 TriggerZone로 바꾸고 Position을 (2, 0.5, 5)로 둔다. SphereCollider의 Is Trigger를 체크한다. Layer는 TriggerOnly 같은 새 레이어로 만들어 할당한다. 마지막으로 Hierarchy 우클릭 → 3D Object → Cube 생성, 이름을 IgnoreMe로 바꾸고 Position을 (-2, 0.5, 5)로 둔 뒤 Layer를 Ignore Raycast로 설정한다. Ignore Raycast는 DefaultRaycastLayers에서 기본적으로 빠진다.
3단계: 코드 작성 및 테스트(무슨 설정에서 miss가 나는지 로그로 고정)
Hierarchy 우클릭 → Create Empty 생성, 이름을 Raycaster로 바꾼다. Main Camera를 선택해서 Position을 (0, 2, -5), Rotation을 (10, 0, 0) 정도로 둔다. Raycaster 오브젝트에 스크립트 2개를 붙인다. 첫 번째는 ‘어떤 레이어를 맞추는지’, 두 번째는 ‘전역 설정과 트리거 옵션’을 바꿔가며 재현하는 용도다.
Inspector에서 Raycaster의 Transform.forward가 Wall을 향하도록 Raycaster를 카메라 근처(0,1,-4)에 둔다. mask는 Obstacle만 포함하도록 체크한다. Play를 누르면 Debug.DrawRay가 초록이면 Wall을 맞춘 상태다. mask에 TriggerOnly를 추가했는데도 miss가 나면 QueryTriggerInteraction 설정이 틀린 상태다. IgnoreMe는 레이어가 Ignore Raycast라서 기본 레이캐스트에는 안 잡히는 게 정상이다.
1using UnityEngine;
2
3public class RaycastPracticeController : MonoBehaviour
4{
5 [Header("Ray")]
6 [SerializeField] private float distance = 20f;
7 [SerializeField] private LayerMask mask;
8
9 [Header("Toggle")]
10 [SerializeField] private bool useGlobalTrigger = true;
11 [SerializeField] private bool forceHitTriggers = false;
12
13 private void Update()
14 {
15 QueryTriggerInteraction qti = QueryTriggerInteraction.UseGlobal;
16 if (!useGlobalTrigger)
17 qti = forceHitTriggers ? QueryTriggerInteraction.Collide : QueryTriggerInteraction.Ignore;
18
19 var ray = new Ray(transform.position, transform.forward);
20 bool hit = Physics.Raycast(ray, out RaycastHit info, distance, mask, qti);
21
22 Debug.DrawRay(ray.origin, ray.direction * distance, hit ? Color.green : Color.red);
23
24 string qtiText = useGlobalTrigger ? $"UseGlobal({Physics.queriesHitTriggers})" : qti.ToString();
25 Debug.Log($"hit={hit} qti={qtiText} maskInt={(int)mask}");
26 if (hit) Debug.Log($"HIT name={info.collider.name} layer={LayerMask.LayerToName(info.collider.gameObject.layer)} trigger={info.collider.isTrigger} point={info.point}");
27 }
28}이 스크립트는 ‘전역 설정을 쓰는지’와 ‘호출 단위로 트리거를 강제하는지’를 분리해서 보여준다. Play 중에 Project Settings → Physics → Queries Hit Triggers를 껐다 켜면, useGlobalTrigger=true일 때만 결과가 바뀐다. 이 순간이 “UseGlobal이 전역값을 읽는다”는 증거다.
1using UnityEngine;
2
3public class LayerMaskSanityCheck : MonoBehaviour
4{
5 [SerializeField] private LayerMask mask;
6
7 private void Start()
8 {
9 Debug.Log($"maskInt={(int)mask}");
10 for (int i = 0; i < 32; i++)
11 {
12 bool included = (mask.value & (1 << i)) != 0;
13 if (!included) continue;
14 Debug.Log($"included layerIndex={i} name={LayerMask.LayerToName(i)}");
15 }
16 }
17
18 private void Update()
19 {
20 // Play 중 Inspector에서 레이어 체크를 바꿔도 즉시 반영되는지 확인
21 if (Input.GetKeyDown(KeyCode.Space))
22 Debug.Log($"[Space] maskInt now={(int)mask}");
23 }
24}이 스크립트를 붙이고 Play를 누르면 포함된 레이어 이름이 콘솔에 줄줄 찍힌다. 레이어 이름이 비어 있거나(빈 문자열), 예상한 레이어가 누락되어 있으면 Inspector에서 체크한 게 다른 오브젝트에 적용됐거나 레이어 생성 자체가 꼬인 상태다. maskInt가 0이면 Raycast는 아무것도 맞출 수 없다.
심화 활용
한 문단 요약: Raycast miss는 ‘레이가 틀렸다’보다 ‘필터가 후보를 제거했다’에서 시작한다. maskInt, QueryTriggerInteraction, Physics 전역 쿼리 설정, Rigidbody 이동 타이밍을 로그로 고정하면 원인을 10분 안에 좁힐 수 있다.
패턴 1: 레이캐스트를 ‘디버그 레이어’로 계측하고, 실패 이유를 로그로 분해
실무에서는 Raycast 실패를 한 줄 로그로 끝내면 재현이 어렵다. 레이의 원점/방향, 마스크 정수값, 트리거 정책, 그리고 ‘무엇을 맞추고 싶었는지(의도 레이어)’를 한 번에 남겨야 한다. 특히 QA가 “가끔 클릭이 안 된다”라고만 올리면, 그 프레임에 어떤 설정이었는지 복원할 방법이 없다.
1using UnityEngine;
2
3public class RaycastDebugProbe : MonoBehaviour
4{
5 [SerializeField] private Camera cam;
6 [SerializeField] private LayerMask intendedMask;
7 [SerializeField] private float maxDistance = 200f;
8 [SerializeField] private QueryTriggerInteraction qti = QueryTriggerInteraction.UseGlobal;
9
10 private void Update()
11 {
12 if (cam == null) cam = Camera.main;
13 if (!Input.GetMouseButtonDown(0)) return;
14
15 Ray ray = cam.ScreenPointToRay(Input.mousePosition);
16 bool hit = Physics.Raycast(ray, out var info, maxDistance, intendedMask, qti);
17
18 Debug.DrawRay(ray.origin, ray.direction * maxDistance, hit ? Color.green : Color.red, 2f);
19
20 string header = $"[RayProbe] hit={hit} maskInt={(int)intendedMask} qti={qti} globalTriggers={Physics.queriesHitTriggers}";
21 Debug.Log(header);
22
23 if (hit)
24 {
25 Debug.Log($"[RayProbe] collider={info.collider.name} layer={LayerMask.LayerToName(info.collider.gameObject.layer)} trigger={info.collider.isTrigger} point={info.point} dist={info.distance}");
26 }
27 else
28 {
29 Debug.Log($"[RayProbe] miss origin={ray.origin} dir={ray.direction} screen={Input.mousePosition}");
30 }
31 }
32}이 코드를 실행하고 마우스 클릭을 하면, 2초 동안 레이가 색으로 남고 콘솔에 필터 정보가 고정된다. hit=false인데도 레이가 오브젝트를 관통하는 것처럼 보이면, Scene 뷰에서 콜라이더가 실제로 있는지부터 의심해야 한다. MeshRenderer만 있고 Collider가 없으면, 렌더링은 되지만 물리 월드에는 등록되지 않는다.
패턴 2: RaycastNonAlloc + 프레임 예산 기반 호출 제한
Raycast를 ‘매 프레임 여러 번’ 쓰는 게임(총기, 상호작용, AI 시야)에서는 쿼리 비용이 누적된다. Raycast 자체는 GC를 잘 만들지 않지만, RaycastAll이 배열을 새로 만들거나, hitInfo.collider 접근을 남발하면 CPU 시간이 튄다. NonAlloc 패턴은 결과 배열을 재사용해서 호출 수를 예측 가능하게 만든다.
1using UnityEngine;
2
3public class RaycastNonAllocScanner : MonoBehaviour
4{
5 [SerializeField] private LayerMask mask;
6 [SerializeField] private float distance = 50f;
7 [SerializeField] private int bufferSize = 16;
8
9 private RaycastHit[] hits;
10
11 private void Awake()
12 {
13 hits = new RaycastHit[Mathf.Max(1, bufferSize)];
14 }
15
16 private void Update()
17 {
18 Ray ray = new Ray(transform.position, transform.forward);
19 int count = Physics.RaycastNonAlloc(ray, hits, distance, mask, QueryTriggerInteraction.Ignore);
20
21 Debug.DrawRay(ray.origin, ray.direction * distance, count > 0 ? Color.cyan : Color.magenta);
22 Debug.Log($"NonAlloc count={count} buffer={hits.Length} maskInt={(int)mask}");
23
24 if (count > 0)
25 {
26 // 가장 가까운 것만 필요하면 정렬 대신 최소 distance를 찾는다
27 int best = 0;
28 float bestDist = hits[0].distance;
29 for (int i = 1; i < count; i++)
30 if (hits[i].distance < bestDist) { bestDist = hits[i].distance; best = i; }
31
32 Debug.Log($"Nearest={hits[best].collider.name} dist={bestDist}");
33 }
34 }
35}이 코드는 hits 배열을 Awake에서 한 번만 만들고 재사용한다. 실행하면 콘솔에 count가 찍히는데, count가 hits.Length와 같다면 버퍼가 부족해서 일부 히트를 잃었을 가능성이 있다. 예를 들어 bufferSize=16인데 count가 16으로 계속 찍히면, 17번째 이후 히트는 잘린다. 이 상태에서 “가끔 상호작용이 안 된다”가 발생한다. 버퍼를 32나 64로 늘리거나, 애초에 레이어를 더 좁혀 후보 수를 줄여야 한다.
내 흑역사 1. UI 클릭을 월드 Raycast로 처리하던 프로젝트에서, 특정 맵에서만 상호작용이 10% 확률로 실패했다. 로그를 더 찍어보니 RaycastNonAlloc count가 항상 16(버퍼 꽉 참)이고, 내가 찾는 상호작용 콜라이더는 17번째 이후로 밀려 있었다. 맵에 장식용 트리거 콜라이더가 대량으로 깔려 있었고, 레이어 분리가 안 돼 있었다. 버퍼를 64로 늘리니 증상은 사라졌지만, 근본 해결은 장식 트리거를 레이어에서 제외하는 거였다.
내 흑역사 2. 3시간 삽질 끝에 알아낸 건 ‘Ignore Raycast 레이어’였다. 레벨 디자이너가 특정 오브젝트를 클릭 불가로 만들려고 레이어를 Ignore Raycast로 바꿨는데, 나는 그걸 모르고 “왜 이 오브젝트만 안 맞지?”를 계속 파고 있었다. 콘솔에 hit.collider.layer를 찍는 순간 끝났다. 디버깅 로그에 레이어 이름을 포함시키는 습관이 그 뒤로 생겼다.
자주 하는 실수
실수 1: LayerMask가 0이거나 의도 레이어가 빠져 있다
증상: Raycast가 항상 false이고, Debug.DrawRay는 오브젝트를 관통하는데도 콘솔에는 miss만 찍힌다. maskInt를 찍으면 0 또는 예상과 전혀 다른 값이 나온다.
원인: Inspector에서 LayerMask를 체크하지 않았거나, 코드에서 mask를 0으로 초기화했다. 또는 레이어를 새로 만들고 오브젝트에 할당하지 않았다. LayerMask는 이름이 아니라 비트라서, 체크 하나가 빠지면 후보가 0이 된다.
해결: Raycaster 스크립트의 mask를 Everything(~0)으로 바꿔서 먼저 맞는지 확인한다. 맞기 시작하면 레이어를 하나씩 줄인다. 콘솔에 (int)mask와 LayerMask.LayerToName(i) 덤프를 남겨, 어떤 레이어가 포함됐는지 고정한다.
실수 2: Ignore Raycast 레이어를 잊고 디버깅한다
증상: 특정 오브젝트만 절대 맞지 않는다. 콜라이더도 있고, 레이 방향도 맞는데 hit이 안 난다. 다른 오브젝트는 잘 맞는다.
원인: 대상 오브젝트 레이어가 Ignore Raycast다. Physics.DefaultRaycastLayers는 Ignore Raycast를 제외한다. 즉, mask를 명시하지 않고 Raycast를 호출하면 영원히 못 맞춘다.
해결: 대상 오브젝트 선택 → Inspector 상단 Layer 확인. Ignore Raycast면 레이어를 바꾸거나, Raycast 호출에서 mask에 Ignore Raycast를 포함시킨다. 디버깅 로그에 hit.collider.gameObject.layer와 LayerToName을 항상 찍는다.
실수 3: Trigger를 맞추려는데 QueryTriggerInteraction이 Ignore다
증상: Trigger 콜라이더만 있는 구역에서 Raycast가 전부 miss다. Project Settings → Physics에서 Queries Hit Triggers를 켜면 갑자기 맞기 시작한다.
원인: Raycast 호출에서 QueryTriggerInteraction.Ignore를 넘겼거나, UseGlobal을 썼는데 전역 Physics.queriesHitTriggers가 false다. 엔진은 후보 콜라이더가 Trigger인 경우 최종 필터에서 버린다.
해결: 트리거를 맞춰야 하는 기능(상호작용, 존 체크)은 호출에서 QueryTriggerInteraction.Collide를 명시한다. 전역값에 의존하면 팀원이 Project Settings를 바꾸는 순간 기능이 깨진다.
실수 4: Rigidbody 이동을 Update에서 하고 같은 프레임에 Raycast를 쏜다
증상: 빠르게 움직일 때만 가끔 miss가 난다. 특히 타겟이 Rigidbody를 가지고 있고, Interpolate 설정이 켜져 있을 때 더 헷갈린다. Scene 뷰에서 콜라이더가 렌더 위치와 살짝 다르게 보일 수 있다.
원인: Rigidbody는 물리 스텝에서 위치가 확정된다. Update에서 transform을 직접 바꾸면, 물리 엔진이 그 이동을 즉시 반영하지 않거나 다음 FixedUpdate에서 반영한다. Update에서 쏜 Raycast는 ‘이전 물리 월드’를 읽는다.
해결: Rigidbody 이동은 FixedUpdate에서 rb.MovePosition/MoveRotation을 쓴다. Raycast도 물리 기준이 필요한 기능이면 FixedUpdate에서 쏘거나, Update에서 쏠 경우 타겟이 Transform 기반인지 Rigidbody 기반인지 구분한다.
실수 5: MeshCollider/Backface 때문에 한쪽 면에서만 안 맞는다
증상: 얇은 메쉬(한 장짜리 벽) 뒤쪽에서 Raycast가 안 맞는다. 정면에서는 맞고, 반대편에서는 miss다. Queries Hit Backfaces를 켜면 맞는다.
원인: 메시의 삼각형은 앞면/뒷면이 있고, 기본 설정에서 Raycast는 뒷면(backface)을 무시할 수 있다. 특히 MeshCollider에서 이 차이가 체감된다. 엔진은 삼각형 교차 테스트에서 노멀 방향을 보고 필터링한다.
해결: Project Settings → Physics → Queries Hit Backfaces를 켜서 재현 여부를 확인한다. 얇은 벽이라면 실제 두께를 가진 콜라이더(BoxCollider)로 교체하는 편이 예측 가능하다. MeshCollider를 써야 한다면 설계 단계에서 앞/뒷면 정책을 정한다.
1using UnityEngine;
2
3public class BackfaceDebugRay : MonoBehaviour
4{
5 [SerializeField] private LayerMask mask = ~0;
6 [SerializeField] private float distance = 30f;
7
8 private void Update()
9 {
10 var ray = new Ray(transform.position, transform.forward);
11 bool hit = Physics.Raycast(ray, out var info, distance, mask, QueryTriggerInteraction.Ignore);
12 Debug.DrawRay(ray.origin, ray.direction * distance, hit ? Color.green : Color.red);
13
14 if (Input.GetKeyDown(KeyCode.B))
15 {
16 Physics.queriesHitBackfaces = !Physics.queriesHitBackfaces;
17 Debug.Log($"queriesHitBackfaces={Physics.queriesHitBackfaces}");
18 }
19
20 if (hit) Debug.Log($"HIT {info.collider.name} point={info.point}");
21 }
22}B 키를 누르면 queriesHitBackfaces 전역값이 토글된다. 얇은 메쉬를 대상으로 실험하면, 토글 순간 hit이 바뀌는 걸 콘솔과 Debug.DrawRay로 확인할 수 있다. 전역값을 런타임에서 바꾸는 건 제품 코드에서는 피하는 편이 낫지만, 재현 실험용으로는 강력하다.
성능 최적화 체크리스트
- Raycast 호출에서 layerMask를 항상 명시하고, (int)mask를 로그로 남긴다(0이면 즉시 원인 후보).
- Raycast miss 재현 시 Debug.DrawRay로 레이 원점/방향/길이를 1~2초 남긴다(Scene 뷰에서 확인).
- 대상 오브젝트의 Layer가 Ignore Raycast인지 먼저 확인한다(Inspector 상단 Layer).
- Project Settings → Physics에서 Queries Hit Triggers/Backfaces 상태를 기록하고, UseGlobal 사용 여부를 코드에서 고정한다.
- Trigger를 맞춰야 하는 기능은 QueryTriggerInteraction.Collide를 호출 단위로 명시한다(전역값 의존 제거).
- Rigidbody를 가진 오브젝트 이동은 FixedUpdate + MovePosition/MoveRotation으로 통일하고, Transform 직접 이동을 섞지 않는다.
- Raycast를 Update에서 쏘는 기능은 ‘렌더 위치’ 기준인지 ‘물리 월드’ 기준인지 문서화한다(팀 내 규칙).
- RaycastAll 사용 시 배열 할당/정렬 비용을 의식하고, 가능하면 RaycastNonAlloc + 버퍼 크기 모니터링을 쓴다.
- RaycastHit.collider 접근 횟수를 줄인다(맞았는지 판단만 필요하면 collider/name 접근을 지연).
- Layer Collision Matrix는 접촉 해결용이고, 쿼리 필터(레이어/트리거/백페이스)와 별개라는 점을 디버깅 체크리스트에 포함한다.
- MeshCollider를 클릭/조준에 쓰면 Backface 정책을 정한다(두께 콜라이더로 대체 가능 여부 검토).
- Profiler에서 Physics.Raycast 관련 CPU 시간을 확인하고, 프레임당 쿼리 횟수 목표를 정한다(예: 60fps 기준 200회 이하부터 시작).
자주 묻는 질문
Raycast가 항상 false인데 콜라이더가 분명히 있다. 가장 먼저 뭘 확인해야 하나?
레이어 마스크 정수값과 대상 오브젝트 레이어부터 본다. Physics.Raycast는 네이티브 쿼리로 들어가면서 layerMask(int)로 후보를 1차 제거한다. mask가 0이면 후보가 0이라서 레이 방향이 아무리 정확해도 hit=false가 정상이다. 예를 들어 Obstacle만 맞추려면 Obstacle이 8번 레이어일 때 maskInt가 256이어야 한다. 다음으로 대상 레이어가 Ignore Raycast인지 확인한다. Ignore Raycast는 Physics.DefaultRaycastLayers에서 제외되는 정책이라, mask를 생략한 Raycast는 못 맞춘다. 실전에서는 mask를 ~0으로 바꿔 1회만 테스트하고(Everything로 맞는지 확인), 맞기 시작하면 의도 레이어만 남기며 좁힌다. Debug.DrawRay를 2초(지속시간 2f)로 남겨 Scene 뷰에서 원점/방향이 실제로 벽을 향하는지도 같이 확인한다.
Layer Collision Matrix에서 충돌 체크가 켜져 있는데도 Raycast가 안 맞는 이유는 뭔가?
Layer Collision Matrix는 ‘물리 접촉 해결(충돌 반응)’을 제어한다. Raycast는 ‘쿼리’라서 다른 필터를 탄다. Raycast는 layerMask, QueryTriggerInteraction, queriesHitTriggers/backfaces 같은 쿼리 설정을 우선 적용한다. 그래서 매트릭스가 켜져 있어도 Raycast는 트리거를 무시하거나 특정 레이어를 마스크에서 빼면 miss가 난다. 반대로 매트릭스가 꺼져 있어도 Raycast는 맞을 수 있다. 재현 테스트는 3개 로그로 끝난다: (1) maskInt (2) qti=UseGlobal인지/Ignore인지/Collide인지 (3) Physics.queriesHitTriggers/queriesHitBackfaces 값. 예를 들어 isTrigger=true 오브젝트만 있는 씬에서 queriesHitTriggers=false + UseGlobal이면 100% miss가 난다. 이 케이스는 호출 인자를 QueryTriggerInteraction.Collide로 바꾸는 즉시 해결된다.
Trigger 콜라이더를 Raycast로 맞추고 싶다. 전역 설정을 켜는 게 맞나?
제품 코드에서는 호출 단위로 명시하는 편이 안전하다. 전역 Physics.queriesHitTriggers는 팀원이 프로젝트 설정을 바꾸는 순간 전체 기능이 같이 흔들린다. 상호작용, 감지 존, UI 대체 클릭 같은 기능은 QueryTriggerInteraction.Collide를 Raycast 호출 인자로 박아두면 재현성이 올라간다. 예를 들어 상호작용 레이는 distance=3f, mask=Interactable(예: 1<<10=1024), qti=Collide로 고정하고, 총알/시야 레이는 qti=Ignore로 고정한다. ‘UseGlobal’은 실험에는 편하지만, 기능 단위 정책을 전역으로 묶어버리는 설계가 된다.
Update에서 Raycast를 쐈는데 움직이는 Rigidbody를 가끔 놓친다. 버그인가?
버그라기보다 타이밍 문제다. Unity 물리는 고정 스텝(FixedUpdate)에서 진행되고, Update는 프레임마다 호출된다. Rigidbody가 있는 오브젝트는 물리 스텝에서 위치가 확정되며, 렌더링은 보간(Interpolate)로 더 부드럽게 보일 수 있다. 그래서 Update에서 보이는 위치를 기준으로 레이를 쏘면, 물리 월드의 콜라이더 위치와 어긋나 miss가 날 수 있다. 해결은 두 갈래다. 1) 물리 기준 판정이면 FixedUpdate에서 쿼리한다(예: 50Hz fixedDeltaTime=0.02에서 안정적) 2) Update에서 해야 한다면 Rigidbody 이동을 FixedUpdate로 통일하고, 레이 원점을 카메라가 아니라 물리 기준 트랜스폼(플레이어 캡슐 콜라이더 중심)으로 잡는다. 재현 로그는 Update/FixedUpdate 둘 다 같은 레이를 쏘고 hitPoint를 찍으면 바로 드러난다.
RaycastAll을 쓰면 편한데 성능이 걱정된다. 어떤 비용이 생기나?
RaycastAll은 ‘모든 히트’를 배열로 반환하는 API라서, 호출 시점에 결과 배열을 새로 할당하는 패턴이 흔하다(버전에 따라 내부 최적화가 있어도, 사용자가 받는 배열은 새로 생기는 경우가 많다). 이 배열 생성은 GC 부담으로 이어지고, 히트가 많으면 후처리 비용도 커진다. 반면 RaycastNonAlloc은 사용자가 준비한 버퍼를 재사용하므로 GC를 통제할 수 있다. 다만 버퍼가 작으면 일부 히트를 잃고, 그게 ‘가끔 안 된다’로 보일 수 있다. 예를 들어 bufferSize=16인데 count가 16으로 자주 찍히면, 상호작용 대상이 17번째 이후로 밀려 누락될 수 있다. 해결은 (1) 레이어를 더 좁혀 후보 수를 줄이고 (2) count==bufferSize인 프레임 비율을 로그로 모니터링한 뒤 (3) 32/64로 버퍼를 조정하는 방식이 안전하다.
MeshCollider가 붙은 얇은 벽이 한쪽에서만 Raycast가 안 맞는다. 왜 그런가?
메쉬 삼각형은 앞면/뒷면이 있고, Raycast가 뒷면을 맞출지 여부는 전역 Physics.queriesHitBackfaces와 콜라이더/메쉬 조건에 영향을 받는다. 얇은 벽(한 장 폴리곤)은 한쪽에서 쏘면 ‘뒷면’으로 판정될 수 있어 miss가 난다. Queries Hit Backfaces를 켜면 맞기 시작한다면 원인은 거의 확정이다. 실무 해결은 두께가 있는 BoxCollider로 대체하는 쪽이 예측 가능하다. MeshCollider를 유지해야 한다면, 기능별로 backface를 허용할지(예: 총알은 허용, 클릭은 비허용) 정책을 문서화하고, 전역 토글 대신 설계로 해결한다.
RaycastHit.point가 (0,0,0)처럼 보이거나 이상한 값이 나온다. 어떤 상황에서 그런가?
hit=false인데 point를 찍으면 기본값(Vector3.zero)이 나온다. 초보가 가장 많이 착각하는 패턴이다. RaycastHit은 struct라서 out으로 채워지지 않으면 기본값이 유지된다. 그래서 로그에 point만 찍으면 ‘원점에 맞았다’처럼 보이지만 실제로는 miss다. 해결은 hit bool을 먼저 로그로 찍고, hit일 때만 point/collider를 출력하는 습관이다. 또 하나는 Ray 방향이 NaN이 되는 경우다. direction을 normalized할 때 입력 벡터가 (0,0,0)이면 NaN이 될 수 있고, 네이티브 쿼리가 비정상 입력으로 처리되어 miss가 난다. 실전에서는 dir.sqrMagnitude가 0인지 먼저 체크하고, float.IsNaN(dir.x) 같은 검증 로그를 한 번 넣으면 원인이 빨리 고정된다.