5. Raycast 성능 최적화: LayerMask와 maxDistance로 물리 쿼리 줄이기
Unity Raycast가 네이티브 물리 월드에서 어떻게 처리되는지부터 LayerMask·maxDistance로 브로드페이즈 후보를 줄여 CPU 시간을 아끼는 방법까지 다룬다. 초보도 재현 가능하게 구성했다. 7개 FAQ 포함. GC와 PlayerLoop 관점 설명.
Raycast 성능 최적화: LayerMask와 maxDistance로 물리 쿼리 줄이기
게임 만들다 겪는 사고로 제일 흔한 게, 마우스 오버 하이라이트나 총알 조준선 때문에 Update에서 Raycast를 뿌렸더니 특정 맵에서만 FPS가 60→35로 떨어지는 상황이다. 오브젝트 수가 늘면 Raycast 한 번이 ‘선 하나 쏘는’ 수준이 아니라 네이티브 물리 월드에서 후보 콜라이더를 찾고, 실제 교차 테스트까지 하는 쿼리로 바뀐다. LayerMask와 maxDistance는 그 후보군을 줄이는 가장 싸고 확실한 손잡이다. 왜 이 두 인자가 CPU 시간을 깎는지, 엔진이 내부에서 어떤 경로로 처리하는지까지 연결해서 이해해야 같은 실수를 반복하지 않는다.
핵심 개념
Raycast는 ‘선분(원점+방향+길이)’과 물리 월드의 콜라이더 교차를 묻는 쿼리다. 초보 입장에서는 Debug.DrawRay로 눈에 보이는 선을 쏘니 가벼운 연산처럼 느껴진다. 실제 비용은 선을 그리는 게 아니라, 물리 월드(네이티브 C++)가 “맞을 가능성이 있는 콜라이더 후보 집합”을 만들고, 그 후보에 대해 교차 테스트를 수행하는 데서 나온다.
LayerMask는 후보 집합을 만드는 단계(브로드페이즈)에서 필터로 작동한다. 레이어는 GameObject에 붙는 메타데이터처럼 보이지만, 물리 엔진은 콜라이더가 속한 레이어를 쿼리 필터로 빠르게 거를 수 있게 유지한다. 같은 씬, 같은 Ray라도 마스크를 비워두면 “모든 레이어”가 대상이 되고, 마스크를 좁히면 “애초에 후보로 올리지 않는” 콜라이더가 생긴다.
maxDistance는 선분의 길이를 제한한다. 물리 쿼리는 무한 광선이 아니라 선분으로 처리되는 경우가 많고, 길이가 짧아지면 브로드페이즈에서 선분이 겹치는 공간(예: BVH/AABB 트리 탐색 범위)이 줄어든다. 즉, 멀리 있는 콜라이더를 후보로 올릴 일이 줄어든다. ‘어차피 맞는 건 가까운 것’인 UI 피킹, 상호작용, 근접 공격 판정에서 특히 효과가 크다.
RaycastHit는 결과 구조체다. C#에서는 값 타입이라 한 번의 Raycast로 GC가 터지지 않는다고 착각하기 쉽다. 하지만 RaycastAll처럼 배열을 새로 만들거나, 문자열 로그를 매 프레임 찍거나, LINQ로 후처리하면 그때부터 GC Alloc이 발생한다. 최적화는 물리 쿼리 비용(CPU)과 관리 힙 비용(GC)을 같이 본다.
용어를 5개만 잡고 간다. 브로드페이즈: 후보 콜라이더를 공간 자료구조로 빠르게 추리는 단계. 내로우페이즈: 실제 콜라이더 형태(박스/캡슐/메시 등)로 교차를 계산하는 단계. LayerMask: 후보 필터. maxDistance: 선분 길이. QueryTriggerInteraction: 트리거를 후보에 포함할지 정책.
1using UnityEngine;
2
3public class BasicRaycastWithFilters : MonoBehaviour
4{
5 [Header("Ray Settings")]
6 [SerializeField] private Camera cam;
7 [SerializeField] private LayerMask interactableMask;
8 [SerializeField] private float maxDistance = 3.0f;
9
10 private void Reset()
11 {
12 cam = Camera.main;
13 interactableMask = LayerMask.GetMask("Interactable");
14 maxDistance = 3.0f;
15 }
16
17 private void Update()
18 {
19 if (cam == null) return;
20
21 Ray ray = cam.ScreenPointToRay(Input.mousePosition);
22 bool hit = Physics.Raycast(ray, out RaycastHit hitInfo, maxDistance, interactableMask, QueryTriggerInteraction.Ignore);
23
24 Debug.DrawRay(ray.origin, ray.direction * maxDistance, hit ? Color.green : Color.red);
25 if (hit)
26 Debug.Log("Hit: " + hitInfo.collider.name + " dist=" + hitInfo.distance.ToString("F2"));
27 }
28}이 코드를 실행하면 Scene 뷰에 빨간/초록 레이가 보이고, 맞으면 콘솔에 Hit가 찍힌다. 중요한 관찰 포인트는 두 가지다. 첫째, maxDistance를 3에서 30으로 바꾸면 멀리 있는 콜라이더까지 후보가 늘어날 가능성이 커진다. 둘째, interactableMask를 Everything으로 바꾸면 ‘상호작용과 무관한’ 바닥, 벽, 장식 콜라이더까지 후보에 섞인다. 같은 기능인데도 물리 월드가 훑는 범위가 달라진다.
엔진 관점에서의 내부 동작
C#에서 Physics.Raycast를 호출하면, 실제 연산은 네이티브 물리 엔진 쪽에서 수행된다. C# API는 래퍼이고, 내부적으로는 바인딩 레이어를 통해 C++ Runtime으로 넘어간다. 이때 Ray(원점/방향), maxDistance, layerMask, QueryTriggerInteraction 같은 값 타입 파라미터가 네이티브 호출 규약에 맞게 전달된다. 구조체를 넘기는 자체는 싸지만, 호출 횟수가 많아지면 ‘네이티브 경계 crossing’ 비용이 누적된다.
Player Loop 관점에서 Raycast 호출 시점은 ‘스크립트가 실행되는 프레임 단계’에 종속된다. Update에서 호출하면 매 렌더 프레임마다 물리 월드에 쿼리를 날린다. FixedUpdate에서 호출하면 물리 스텝(기본 0.02s)마다 호출된다. 중요한 점은 Update가 FixedUpdate보다 더 자주 실행될 수 있다는 사실이고, 그래서 같은 코드를 Update에 두면 호출 횟수가 2~3배로 늘어나는 경우가 흔하다.
물리 월드는 매 프레임 ‘물리 시뮬레이션’을 돌린 뒤 공간 자료구조(브로드페이즈)를 업데이트한다. Unity의 내부 구현은 버전과 설정에 따라 다를 수 있지만, 핵심은 콜라이더가 AABB 같은 바운딩 정보를 갖고 있고, 쿼리가 들어오면 이 공간 구조를 탐색해 후보를 추린다는 점이다. LayerMask는 이 후보 추림에서 필터로 작동한다. 후보에 올리지 않으면 내로우페이즈로 내려가지 않으니 교차 테스트도 생략된다.
maxDistance는 브로드페이즈 탐색 범위를 축소한다. 레이가 무한이면 공간 구조를 더 깊고 넓게 탐색해야 하고, 선분이 짧으면 탐색 중 ‘이 구간에 닿을 수 없는 노드’를 빨리 배제할 수 있다. 특히 상호작용 거리(2~5m) 같은 짧은 선분은 후보 수를 눈에 띄게 줄인다. 이건 레이어 필터와 곱으로 작동한다. 레이어로 후보를 줄이고, 거리로 공간 범위를 줄인다.
메모리 관점에서 Physics.Raycast 단일 호출은 보통 관리 힙 할당을 만들지 않는다. RaycastHit는 struct라서 스택/레지스터로 다루고, out 파라미터로 채워진다. 반대로 Physics.RaycastAll은 RaycastHit[]를 새로 만들어 반환하므로 호출할 때마다 배열 할당이 일어날 수 있다. ‘Raycast가 느리다’라는 말에는 CPU 쿼리 비용과, RaycastAll/후처리로 생긴 GC가 섞여 있는 경우가 많다.
내가 처음에 이게 왜 이렇게 동작하는지 몰랐던 지점이 여기다. ‘out RaycastHit니까 힙 할당 없겠지’라고 생각하고 RaycastAll을 섞어서 쓰다가, Profiler에서 GC Alloc이 프레임마다 3~10KB씩 튀었다. 3시간 삽질 끝에 Timeline에서 Physics.RaycastAll → GC.Alloc이 바로 붙어 있는 걸 보고 원인을 확정했다. 배열 반환 API는 결과가 0개여도 배열을 만들 수 있다.
1using UnityEngine;
2
3public class PlayerLoopRaycastProbe : MonoBehaviour
4{
5 [SerializeField] private LayerMask mask = ~0;
6 [SerializeField] private float maxDistance = 10f;
7
8 private int fixedCount;
9 private int updateCount;
10
11 private void FixedUpdate()
12 {
13 fixedCount++;
14 Physics.Raycast(transform.position, transform.forward, maxDistance, mask);
15 }
16
17 private void Update()
18 {
19 updateCount++;
20 Physics.Raycast(transform.position, transform.forward, maxDistance, mask);
21
22 if (Time.frameCount % 60 == 0)
23 Debug.Log("Update calls=" + updateCount + " Fixed calls=" + fixedCount + " (per ~60 frames)");
24 }
25}이 코드를 빈 씬에서 실행하면 60프레임마다 Update와 FixedUpdate의 호출 누적값이 콘솔에 찍힌다. VSync, 타겟 프레임, Fixed Timestep( Project Settings → Time → Fixed Timestep )에 따라 두 값의 비율이 달라진다. 같은 Raycast라도 호출 횟수 자체가 비용의 1차 요인이라는 사실이 눈으로 확인된다.
1using UnityEngine;
2
3public class LayerMaskCandidateCountDemo : MonoBehaviour
4{
5 [SerializeField] private Camera cam;
6 [SerializeField] private float maxDistance = 50f;
7 [SerializeField] private LayerMask everythingMask = ~0;
8 [SerializeField] private LayerMask onlyInteractable;
9
10 private void Reset()
11 {
12 cam = Camera.main;
13 onlyInteractable = LayerMask.GetMask("Interactable");
14 }
15
16 private void Update()
17 {
18 if (cam == null) return;
19
20 Ray ray = cam.ScreenPointToRay(Input.mousePosition);
21
22 bool hitAll = Physics.Raycast(ray, out RaycastHit hitA, maxDistance, everythingMask);
23 bool hitFiltered = Physics.Raycast(ray, out RaycastHit hitB, maxDistance, onlyInteractable);
24
25 if (Time.frameCount % 30 == 0)
26 {
27 string a = hitAll ? hitA.collider.name : "none";
28 string b = hitFiltered ? hitB.collider.name : "none";
29 Debug.Log("Everything hit=" + a + " | Interactable hit=" + b);
30 }
31 }
32}이 코드를 실행하면 30프레임마다 두 레이캐스트 결과가 번갈아 찍힌다. 씬에 바닥(Default)과 상호작용 오브젝트(Interactable)가 섞여 있으면, Everything은 바닥을 먼저 맞는 경우가 많고 Interactable은 바닥을 무시한다. 기능적으로도 달라지고, 성능적으로도 후보 레이어가 줄어든다. “레이어는 기능 분기용”이라는 생각에서 한 단계 더 들어가서 “물리 후보 집합 필터”로 본다.
실습하기
1단계: 프로젝트 설정
Unity 2022.3 LTS 또는 2023.2+에서 3D(Core) 템플릿으로 생성한다. Project Settings → Physics에서 ‘Queries Hit Triggers’ 체크 상태를 확인한다. 이 옵션은 기본 쿼리 정책에 영향을 주고, 코드에서 QueryTriggerInteraction을 명시하지 않으면 예상과 다른 결과가 나온다.
레이어를 만든다. 메뉴 Edit → Project Settings → Tags and Layers → Layers에서 빈 슬롯에 Interactable을 추가한다. 그다음 Project Settings → Physics → Layer Collision Matrix에서 Interactable과 Default의 충돌 관계를 눈으로 확인한다. 이 매트릭스는 ‘시뮬레이션 충돌’용이고, Raycast는 기본적으로 여기에 더해 layerMask로 한 번 더 필터링된다.
2단계: 씬 구성
Hierarchy 우클릭 → 3D Object → Plane로 바닥을 만든다. 이름을 Ground로 바꾸고 Layer는 Default로 둔다. Hierarchy 우클릭 → 3D Object → Cube를 10개 정도 만들고, 위치를 (0,0.5,2)부터 (0,0.5,30)까지 띄엄띄엄 배치한다.
큐브 중 2~3개만 Layer를 Interactable로 바꾼다(Inspector 상단 Layer 드롭다운). 나머지는 Default로 둔다. Camera는 Main Camera를 사용한다. Scene 뷰에서 카메라가 큐브 라인을 정면으로 보게 배치하면, 마우스 위치에 따라 레이가 바닥/큐브를 번갈아 맞는다.
1using UnityEngine;
2
3public class RaycastPerfHarness : MonoBehaviour
4{
5 [Header("Scene")]
6 [SerializeField] private Camera cam;
7
8 [Header("Ray")]
9 [SerializeField] private LayerMask mask;
10 [SerializeField] private float maxDistance = 5f;
11 [SerializeField] private int raysPerFrame = 200;
12
13 [Header("Debug")]
14 [SerializeField] private bool drawRay;
15
16 private void Reset()
17 {
18 cam = Camera.main;
19 mask = LayerMask.GetMask("Interactable");
20 maxDistance = 5f;
21 raysPerFrame = 200;
22 drawRay = false;
23 }
24
25 private void Update()
26 {
27 if (cam == null) return;
28
29 Ray ray = cam.ScreenPointToRay(Input.mousePosition);
30 int hitCount = 0;
31
32 for (int i = 0; i < raysPerFrame; i++)
33 {
34 if (Physics.Raycast(ray, out RaycastHit hit, maxDistance, mask, QueryTriggerInteraction.Ignore))
35 hitCount++;
36 }
37
38 if (drawRay)
39 Debug.DrawRay(ray.origin, ray.direction * maxDistance, hitCount > 0 ? Color.green : Color.red);
40
41 if (Time.frameCount % 30 == 0)
42 Debug.Log("hits=" + hitCount + ", raysPerFrame=" + raysPerFrame + ", dist=" + maxDistance + ", mask=" + mask.value);
43 }
44}이 스크립트를 빈 GameObject에 붙인다. Hierarchy 우클릭 → Create Empty로 RaycastTester를 만들고, RaycastPerfHarness를 추가한다. Inspector에서 Mask를 Interactable로 지정하고, Max Distance를 5로 둔다. Play를 누르면 콘솔에 hits와 설정 값이 찍힌다. 여기서 Max Distance를 50으로 바꾸거나 Mask를 Everything으로 바꾸면, 같은 raysPerFrame이라도 Profiler에서 Physics.Raycast의 CPU 시간이 달라진다.
3단계: Profiler로 확인
Window → Analysis → Profiler를 연다. CPU Usage 모듈에서 Timeline을 선택하고, Deep Profile은 일단 끈다(Deep Profile은 자체 오버헤드가 커서 Raycast 비용이 왜곡된다). Play 상태에서 raysPerFrame=200, maxDistance=5, mask=Interactable로 5초 정도 기록한다.
그다음 같은 씬에서 두 가지만 바꿔 기록한다. (1) mask=Everything, (2) maxDistance=50. Timeline에서 Scripts → RaycastPerfHarness.Update 아래에 Physics.Raycast 호출이 보이고, Self/Total 시간이 변한다. Game 뷰는 큰 변화가 없는데 CPU만 튀는 패턴이 여기서 나온다.
1using System.Diagnostics;
2using UnityEngine;
3
4public class StopwatchRaycastMeter : MonoBehaviour
5{
6 [SerializeField] private LayerMask mask = ~0;
7 [SerializeField] private float maxDistance = 20f;
8 [SerializeField] private int raysPerFrame = 500;
9
10 private readonly Stopwatch sw = new Stopwatch();
11
12 private void Update()
13 {
14 Vector3 o = transform.position;
15 Vector3 d = transform.forward;
16
17 sw.Restart();
18 int hits = 0;
19 for (int i = 0; i < raysPerFrame; i++)
20 if (Physics.Raycast(o, d, maxDistance, mask)) hits++;
21 sw.Stop();
22
23 if (Time.frameCount % 30 == 0)
24 UnityEngine.Debug.Log("Raycast batch ms=" + sw.Elapsed.TotalMilliseconds.ToString("F3") + ", hits=" + hits);
25 }
26}Stopwatch는 에디터/플레이어 환경에 따라 편차가 있지만, 같은 머신에서 설정을 바꿔 비교할 때는 유용하다. 실행하면 30프레임마다 ‘Raycast batch ms’가 찍힌다. mask를 Interactable로 줄이고 maxDistance를 5로 줄이면 수치가 내려가는 경향이 나온다. 수치가 안 내려가면 씬에 콜라이더가 너무 적거나, 레이가 어차피 가까운 것만 스치고 있는 구성이어서 후보 수가 바뀌지 않는 경우다.
심화 활용
한 문단 요약: Raycast 최적화의 핵은 ‘호출 횟수’(Update 난사 방지)와 ‘후보 집합 크기’(LayerMask, maxDistance, Trigger 정책)다. 후보를 줄이면 브로드페이즈 탐색과 내로우페이즈 교차 테스트가 같이 줄고, RaycastAll 같은 할당형 API를 피하면 GC 스파이크도 같이 사라진다.
패턴 1: 상호작용 Raycast는 거리·레이어를 하드하게 고정한다
상호작용(문 열기, 아이템 줍기)은 디자인적으로도 ‘유효 거리’가 있다. 이걸 코드에서 maxDistance로 고정하면, 물리 월드가 멀리 있는 장식 콜라이더를 후보로 올릴 이유가 사라진다. 레이어도 Interactable만 대상으로 고정한다. 기능 요구사항 자체가 필터링 근거가 된다.
1using UnityEngine;
2
3public class InteractRaycaster : MonoBehaviour
4{
5 [SerializeField] private Transform origin;
6 [SerializeField] private float interactDistance = 2.5f;
7 [SerializeField] private LayerMask interactMask;
8
9 private IInteractable current;
10
11 private void Reset()
12 {
13 origin = transform;
14 interactDistance = 2.5f;
15 interactMask = LayerMask.GetMask("Interactable");
16 }
17
18 private void Update()
19 {
20 if (origin == null) return;
21
22 bool hit = Physics.Raycast(origin.position, origin.forward, out RaycastHit h,
23 interactDistance, interactMask, QueryTriggerInteraction.Ignore);
24
25 IInteractable next = null;
26 if (hit) next = h.collider.GetComponentInParent<IInteractable>();
27
28 if (next != current)
29 {
30 if (current != null) current.OnFocus(false);
31 current = next;
32 if (current != null) current.OnFocus(true);
33 }
34
35 if (current != null && Input.GetKeyDown(KeyCode.E))
36 current.Interact();
37 }
38}
39
40public interface IInteractable
41{
42 void OnFocus(bool focused);
43 void Interact();
44}이 코드를 붙이면, 플레이 중 E를 눌렀을 때 가까운 상호작용 오브젝트만 반응한다. 콘솔 로그 대신 하이라이트 머티리얼 변경 같은 시각 피드백을 넣으면 더 확실히 체감된다. GetComponentInParent는 맞은 콜라이더에서 부모로 올라가며 탐색하니, 상호작용 오브젝트는 콜라이더를 자식에 두고 루트에 스크립트를 두는 구성이 편해진다. 단, 이 탐색도 매 프레임이면 비용이 된다. current 캐싱으로 ‘포커스가 바뀔 때만’ 호출되게 만든 이유가 여기다.
패턴 2: RaycastNonAlloc으로 결과 배열 할당을 없앤다
총알 관통, 레이저, 다중 타겟 스캔처럼 ‘한 레이에서 여러 히트’를 처리할 때 RaycastAll을 쓰기 쉽다. RaycastAll은 히트 배열을 반환하므로 호출 빈도가 높으면 GC가 쌓인다. NonAlloc 계열은 호출자가 배열을 들고 있고, 엔진이 그 배열에 결과를 채운다. 관리 힙 할당을 호출자 쪽에서 통제할 수 있다.
1using UnityEngine;
2
3public class LaserScanNonAlloc : MonoBehaviour
4{
5 [SerializeField] private Transform origin;
6 [SerializeField] private float maxDistance = 30f;
7 [SerializeField] private LayerMask targetMask;
8 [SerializeField] private int bufferSize = 16;
9
10 private RaycastHit[] hits;
11
12 private void Awake()
13 {
14 hits = new RaycastHit[Mathf.Max(1, bufferSize)];
15 }
16
17 private void Reset()
18 {
19 origin = transform;
20 maxDistance = 30f;
21 targetMask = LayerMask.GetMask("Enemy", "Interactable");
22 bufferSize = 16;
23 }
24
25 private void Update()
26 {
27 if (origin == null) return;
28
29 Ray r = new Ray(origin.position, origin.forward);
30 int count = Physics.RaycastNonAlloc(r, hits, maxDistance, targetMask, QueryTriggerInteraction.Ignore);
31
32 if (Time.frameCount % 30 == 0)
33 Debug.Log("NonAlloc hit count=" + count + ", buffer=" + hits.Length);
34
35 for (int i = 0; i < count; i++)
36 {
37 Collider c = hits[i].collider;
38 Debug.DrawLine(r.origin, hits[i].point, Color.cyan, 0f, false);
39 }
40 }
41}실행하면 맞은 지점까지 시안색 라인이 그려지고, 30프레임마다 hit count가 찍힌다. 적이 많아 count가 bufferSize를 넘으면 잘린다. 이게 NonAlloc의 트레이드오프다. 실무에서는 ‘최대 동시 히트 수’를 디자인/레벨에서 상한으로 잡거나, 부족하면 bufferSize를 올리되 프레임마다 재할당하지 않게 Awake에서 한 번만 만든다.
내 흑역사 1개가 여기서 나왔다. RaycastAll을 무기마다 Update에서 돌렸는데, 모바일에서 2~3분 플레이 후 갑자기 200ms 프리즈가 났다. Profiler에서 GC.Collect가 튀었고, Allocation Callstacks를 켜니 RaycastHit[] 생성이 계속 보였다. ‘히트가 많을 때만 느리겠지’라고 생각했는데, 히트가 0이어도 배열은 만들어진다. NonAlloc로 바꾸고, 레이어를 Enemy만 남기고, maxDistance를 50→25로 줄이니 프리즈가 사라졌다.
내 흑역사 2개는 LayerMask 실수다. 상호작용 마스크를 Default까지 포함해놨더니, 문 손잡이 대신 벽이 먼저 맞아서 상호작용이 끊겼다. 디버깅은 간단했다. Debug.DrawRay는 초록인데 상호작용 UI가 안 뜨는 상황에서, 콘솔에 hit.collider.name을 찍으니 항상 ‘Wall’만 나왔다. 레이어 분리를 제대로 하고 마스크를 Interactable만 남기니 기능과 성능이 동시에 해결됐다.
자주 하는 실수
실수 1: layerMask를 기본값(Everything)으로 둔다
증상: 상호작용 레이가 바닥/벽/장식에 먼저 맞아서 기능이 흔들리고, 오브젝트가 많은 씬에서만 CPU Usage가 상승한다. Profiler Timeline에서 Scripts 아래 Physics.Raycast가 눈에 띈다.
원인: Everything은 물리 월드의 거의 모든 콜라이더를 후보로 올린다. 브로드페이즈에서 후보가 늘고, 내로우페이즈 교차 테스트도 늘어난다. 기능적으로도 ‘원치 않는 콜라이더가 먼저 히트’되는 문제가 섞인다.
해결: 상호작용, 총알, 지면 체크처럼 목적이 명확한 레이는 전용 레이어로 분리하고, LayerMask.GetMask로 명시한다. Inspector에서 Mask 필드를 Everything 대신 Interactable, Enemy, Ground 같은 최소 집합으로 고정한다.
실수 2: maxDistance를 습관적으로 9999로 둔다
증상: 멀리 있는 오브젝트까지 맞아서 조준/피킹이 이상해지고, 맵이 커질수록 특정 프레임에서만 스파이크가 생긴다. 특히 대형 메시 콜라이더가 있는 레벨에서 튄다.
원인: 선분이 길면 공간 자료구조 탐색 범위가 커지고 후보가 늘어난다. 멀리 있는 콜라이더는 대부분 게임플레이에 의미가 없는데도 후보에 들어간다. 내로우페이즈가 메시 콜라이더까지 내려가면 비용이 더 커진다.
해결: 디자인 요구사항으로 거리 상한을 정한다. 상호작용 2~5m, 피킹 50~200m, 총알은 무기별 사거리로 상한을 둔다. Inspector에서 maxDistance를 숫자로 고정하고, 코드에서 상수/SerializeField로 노출해 튜닝한다.
실수 3: RaycastAll을 매 프레임 호출하고 GC를 못 본다
증상: 플레이 1~3분 후 주기적으로 프리즈가 온다. 콘솔에는 아무 에러도 없고, 프레임이 순간적으로 멈춘다. Profiler에서 GC.Collect 또는 GarbageCollector가 튄다.
원인: RaycastAll은 RaycastHit[]를 반환한다. 반환 배열은 관리 힙 객체라 누적되면 GC가 회수한다. 결과가 0개여도 배열이 만들어지는 구현/버전에 걸리면 더 자주 터진다.
해결: Physics.RaycastNonAlloc을 사용하고, 결과 버퍼는 Awake에서 한 번만 할당한다. 후처리에서 LINQ, string concatenation 로그를 매 프레임 하지 않는다. Profiler에서 GC Alloc 컬럼을 켜고 0B를 목표로 잡는다.
실수 4: 트리거를 의도치 않게 맞는다(또는 못 맞는다)
증상: 보이지 않는 영역 트리거가 레이를 막아서 상호작용이 안 되거나, 반대로 트리거를 맞아야 하는데 레이가 통과한다. 콘솔에는 hit이 나오는데 collider가 TriggerZone 같은 이름으로 찍힌다.
원인: Project Settings → Physics의 Queries Hit Triggers 전역 옵션과, Raycast의 QueryTriggerInteraction 파라미터가 섞여 동작한다. 코드에서 파라미터를 생략하면 전역 옵션에 끌려간다.
해결: Raycast 호출마다 QueryTriggerInteraction을 명시한다. 상호작용은 Ignore, 트리거 감지는 Collide 같은 식으로 의도를 코드에 박아 넣는다. 전역 옵션은 팀 규칙으로 고정하고, 개별 기능은 파라미터로 제어한다.
실수 5: Update에서 레이를 여러 시스템이 중복 발사한다
증상: 기능은 정상인데, 씬이 복잡해질수록 CPU가 꾸준히 오른다. Profiler에서 여러 스크립트의 Update가 각각 Physics.Raycast를 호출하고 있다. 체감은 없는데 배터리/발열이 올라간다.
원인: 카메라 피킹, 상호작용, AI 시야, 무기 조준이 각각 자기 Update에서 Raycast를 쏜다. 호출 횟수는 합산된다. 호출 한 번이 0.01ms여도 500번이면 5ms다.
해결: 레이 발사를 중앙화하거나, 이벤트 기반으로 줄인다. 마우스가 움직일 때만 피킹을 갱신하고, 상호작용은 일정 주기(예: 10Hz)로 샘플링한다. 동일한 Ray를 여러 시스템이 공유하게 만들어 쿼리 횟수를 줄인다.
1using UnityEngine;
2
3public class DuplicateRaycastGuard : MonoBehaviour
4{
5 [SerializeField] private Camera cam;
6 [SerializeField] private LayerMask mask;
7 [SerializeField] private float maxDistance = 5f;
8 [SerializeField] private float sampleInterval = 0.1f;
9
10 private float nextTime;
11 private bool hasCached;
12 private RaycastHit cachedHit;
13
14 private void Reset()
15 {
16 cam = Camera.main;
17 mask = LayerMask.GetMask("Interactable");
18 maxDistance = 5f;
19 sampleInterval = 0.1f;
20 }
21
22 private void Update()
23 {
24 if (cam == null) return;
25
26 if (Time.unscaledTime >= nextTime)
27 {
28 nextTime = Time.unscaledTime + sampleInterval;
29 Ray ray = cam.ScreenPointToRay(Input.mousePosition);
30 hasCached = Physics.Raycast(ray, out cachedHit, maxDistance, mask, QueryTriggerInteraction.Ignore);
31 }
32
33 if (hasCached)
34 Debug.DrawLine(cam.transform.position, cachedHit.point, Color.yellow);
35 }
36}이 코드를 실행하면 레이캐스트가 매 프레임이 아니라 sampleInterval마다만 갱신된다. Game 뷰에서 마우스를 빠르게 움직이면 노란 선이 0.1초 단위로 따라오는 게 보인다. UI 하이라이트 같은 기능은 이 정도 샘플링으로도 충분한 경우가 많고, 호출 횟수는 1/6~1/10로 떨어진다.
성능 최적화 체크리스트
- 상호작용/피킹/총알/지면체크 레이를 목적별로 분리하고, 각 레이에 전용 LayerMask를 사용한다(Everything 금지).
- maxDistance를 디자인 요구사항으로 고정한다(상호작용 2~5m, 피킹 50~200m, 무기 사거리 등).
- Update에서 Raycast 호출 횟수를 숫자로 센다(프레임당 N회). 여러 시스템의 합산 호출을 확인한다.
- Profiler(CPU Usage Timeline)에서 Scripts 아래 Physics.Raycast Self/Total을 확인하고, 설정 변경 전후를 동일 조건으로 비교한다.
- RaycastAll 사용 여부를 검색하고, 반복 호출 경로에 있으면 RaycastNonAlloc 또는 단일 Raycast+상태 캐싱으로 교체한다.
- GC Alloc 컬럼을 켜고, 레이 관련 코드 경로에서 0B를 목표로 잡는다(로그 문자열/배열 반환/LINQ 점검).
- QueryTriggerInteraction을 호출마다 명시해 전역 옵션(Queries Hit Triggers)에 흔들리지 않게 만든다.
- 가능하면 메시 콜라이더를 레이 대상 레이어에서 제외한다(장식은 Primitive Collider로 대체하거나 Raycast 레이어에서 뺀다).
- 레이가 바닥/벽에 먼저 맞는 기능 버그가 있으면, hit.collider.name을 찍고 레이어/거리/콜라이더 배치를 먼저 의심한다.
- 피킹/상호작용은 이벤트 기반으로 줄인다(마우스 이동 시만 갱신, 10Hz 샘플링, 입력 시만 확인 등).
- FixedUpdate에서 필요한 물리 기반 쿼리(지면 체크 등)만 수행하고, 시각/입력 기반 피킹은 Update로 분리한다.
- NonAlloc 버퍼 크기를 실제 최대 히트 수 기준으로 잡고, 부족 시 잘림을 로그로 감지해 튜닝한다.
자주 묻는 질문
LayerMask만 바꿨는데 왜 Raycast CPU 시간이 줄어드나?
Raycast는 네이티브 물리 월드에서 후보 콜라이더를 먼저 추리는 단계가 있다. LayerMask는 그 후보 집합을 만들 때 가장 앞에서 적용되는 필터 중 하나다. 후보가 줄면 브로드페이즈 탐색에서 방문하는 노드/프록시 수가 줄고, 내로우페이즈 교차 테스트로 내려가는 횟수도 줄어든다. 기능적으로도 ‘원치 않는 콜라이더를 먼저 맞는’ 문제가 줄어든다. 다음 학습 키워드는 broadphase, AABB tree, PhysicsScene 쿼리다.
maxDistance를 줄이면 왜 빨라지나? 어차피 첫 히트만 찾는 거 아닌가?
첫 히트만 찾더라도 ‘첫 히트 후보’를 찾기 전까지는 공간 구조를 탐색해야 한다. 선분 길이가 길면 더 많은 공간 노드와 겹치고, 더 많은 콜라이더가 후보로 올라온다. 반대로 maxDistance가 짧으면 레이가 닿을 수 없는 먼 영역을 탐색 중간에 배제할 수 있다. 특히 레벨이 커지고 콜라이더가 많아질수록 차이가 커진다. 다음 학습 키워드는 ray segment, pruning, early-out, broadphase traversal이다.
Update와 FixedUpdate 중 어디에서 Raycast를 해야 하나?
입력/카메라 피킹/상호작용 UI처럼 ‘렌더 프레임과 동기화된 피드백’은 Update가 자연스럽다. 대신 호출을 매 프레임으로 고정하지 말고 거리·레이어 필터를 강하게 걸거나 샘플링 주기를 둔다. 지면 체크, 물리 기반 캐릭터 상태처럼 ‘물리 스텝과 일관성’이 중요한 쿼리는 FixedUpdate가 맞다. 중요한 건 위치다. Update는 프레임마다, FixedUpdate는 고정 스텝마다 실행되므로 호출 횟수가 달라진다. 다음 학습 키워드는 PlayerLoop, Fixed Timestep, interpolation이다.
Physics.Raycast는 GC Alloc이 없는데도 왜 프리즈가 생기나?
단일 Raycast는 보통 관리 힙 할당이 없지만, 프리즈는 다른 요인으로도 생긴다. (1) RaycastAll 같은 배열 반환 API를 반복 호출해 누적 할당이 쌓여 GC.Collect가 터지는 경우, (2) Raycast 결과를 매 프레임 문자열로 합쳐 Debug.Log를 찍어 문자열 할당이 쌓이는 경우, (3) RaycastNonAlloc 버퍼를 매 프레임 new로 만드는 실수도 흔하다. Profiler에서 GC Alloc과 GC.Collect를 같이 확인해야 원인을 분리할 수 있다. 다음 학습 키워드는 allocation callstacks, incremental GC, string allocations다.
Layer Collision Matrix를 설정했는데 Raycast가 여전히 맞는다. 왜 그런가?
Layer Collision Matrix는 주로 ‘시뮬레이션 충돌(접촉/충돌 해결)’을 제어하는 용도다. Raycast 같은 쿼리는 별도의 필터 경로를 갖고 있고, 호출 시 전달한 layerMask가 1차 필터로 작동한다. 또한 Queries Hit Triggers 같은 전역 옵션, QueryTriggerInteraction 파라미터가 쿼리 결과에 영향을 준다. 매트릭스를 바꿨는데도 레이가 맞는다면, Raycast에 전달한 마스크가 Everything인지, 트리거 정책이 의도와 맞는지부터 확인한다. 다음 학습 키워드는 collision vs query, query filtering, trigger queries다.
RaycastNonAlloc을 쓰면 무조건 더 빠른가?
NonAlloc의 1차 목적은 ‘GC를 없애는 것’이다. CPU 자체가 항상 더 빨라지는 건 아니다. 다만 RaycastAll이 만드는 배열 할당과 GC 비용이 사라지면 프레임 타임이 안정된다. 대신 결과가 버퍼 크기를 넘으면 잘리고, 정렬(거리순) 보장이 필요한 경우 추가 처리가 필요할 수 있다. 실무에서는 ‘히트 수 상한’을 디자인으로 제한하거나 버퍼를 넉넉히 잡고, 부족하면 로그로 감지해 튜닝한다. 다음 학습 키워드는 RaycastCommand, jobified queries, hit sorting 전략이다.
레이어를 많이 쪼개면 관리가 어려운데, 최소한 어떻게 나누는 게 맞나?
레이어 분리는 ‘기능 필터’와 ‘성능 필터’를 동시에 만족해야 한다. 최소 권장 분리는 (1) Ground(지면 체크), (2) Interactable(상호작용), (3) Enemy/Target(전투 타겟), (4) IgnoreRaycast(장식/이펙트) 정도다. 그 외는 프로젝트 규모에 따라 늘린다. 중요한 건 Raycast마다 Everything을 쓰지 않게 만드는 것과, 레벨 디자이너가 오브젝트 레이어를 실수 없이 설정할 수 있는 규칙을 문서화하는 것이다. 다음 학습 키워드는 layer governance, prefab validation, editor tooling이다.