13. Rigidbody 질량·중력·드래그로 물리 움직임 만들기와 엔진 내부 흐름
Unity Rigidbody의 mass, useGravity, drag 설정이 FixedUpdate에서 네이티브 물리로 어떻게 반영되는지, 성능·GC·설계 이유까지 함께 설명한다(초보자용). 154자 내외 구성. 154자 내외 구성. 154자 내외 구성.
Rigidbody 질량·중력·드래그로 물리 움직임 만들기와 엔진 내부 흐름
게임 만들다 겪는 사고로 시작한다. 캐릭터가 경사면에서 미끄러지길래 drag를 올렸더니 멈춰야 할 순간에 공중에서 속도가 급격히 줄고, 점프가 이상해지고, 프레임마다 움직임이 흔들린다. Inspector 숫자 몇 개 바꿨을 뿐인데 왜 이런 부작용이 생길까. Rigidbody의 mass·useGravity·drag는 C# 값이 아니라 네이티브 물리 월드의 적분 파라미터라서, PlayerLoop의 특정 시점과 결합되며 의도치 않은 결과를 만든다. 이 글은 그 연결고리를 끊어서 이해시키는 데 초점이 있다.
핵심 개념
Rigidbody는 Transform을 직접 움직이는 컴포넌트가 아니다. 네이티브 물리 월드(PhysX 기반) 안에 ‘동적 강체’라는 별도 객체를 만들고, 그 객체의 위치/회전 결과를 매 FixedUpdate 스텝마다 Transform으로 되돌려쓴다. 그래서 Transform.position을 Update에서 계속 덮어쓰면 물리 결과가 튕기거나 텔레포트처럼 보인다.
mass는 ‘무게’가 아니라 가속도 계산에 들어가는 관성 파라미터다. 같은 힘(AddForce)을 줬을 때 a = F / m이라서 mass가 크면 덜 가속된다. 반대로 중력은 기본적으로 F = m*g로 적용되므로, 중력만 받는 자유낙하에서는 mass가 달라도 가속도는 같게 나온다. 이 때문에 “질량을 키우면 더 빨리 떨어질 줄 알았다” 같은 오해가 자주 생긴다.
useGravity는 단순 토글이 아니다. 네이티브 측에서 해당 바디에 중력 가속도를 더할지 여부를 결정한다. 끄면 중력 항이 적분에서 빠지고, 켜면 Physics.gravity(전역 벡터)가 매 스텝 속도에 누적된다. 전역 중력을 바꾸면 씬 전체의 모든 useGravity 바디가 영향을 받는다.
drag/ angularDrag는 ‘공기저항 흉내’라는 UI 설명보다 더 직접적이다. PhysX는 선속도/각속도에 대해 감쇠 항을 적용한다. 값이 크면 스텝마다 속도가 더 많이 줄어든다. 문제는 이 감쇠가 Fixed Timestep(기본 0.02s)과 곱해져 누적된다는 점이다. 즉, drag를 올리는 순간 프레임레이트가 아니라 물리 스텝 크기에 더 민감한 움직임이 된다.
초보자에게 가장 중요한 구분은 “입력은 Update, 힘/물리는 FixedUpdate”다. Update는 렌더 프레임마다 실행되고, FixedUpdate는 물리 스텝마다 실행된다. 같은 1초라도 호출 횟수가 다르다. 입력을 FixedUpdate에서 받으면 키 입력이 끊겨 보일 수 있고, 힘을 Update에서 주면 프레임레이트에 따라 가속이 달라진다.
1using UnityEngine;
2
3public class RigidbodyBasics : MonoBehaviour
4{
5 [Header("Inspector에서 조절")]
6 public float mass = 1f;
7 public bool useGravity = true;
8 public float drag = 0f;
9 public float angularDrag = 0.05f;
10
11 private Rigidbody _rb;
12
13 private void Awake()
14 {
15 _rb = GetComponent<Rigidbody>();
16 _rb.mass = mass;
17 _rb.useGravity = useGravity;
18 _rb.drag = drag;
19 _rb.angularDrag = angularDrag;
20 }
21
22 private void FixedUpdate()
23 {
24 if (Input.GetKey(KeyCode.W)) _rb.AddForce(Vector3.forward * 10f, ForceMode.Force);
25 if (Input.GetKey(KeyCode.S)) _rb.AddForce(Vector3.back * 10f, ForceMode.Force);
26 }
27}이 스크립트를 실행하면 W/S를 누르는 동안 오브젝트가 ‘물리적으로’ 가속한다. 같은 힘을 주는데 mass를 1→10으로 바꾸면 가속이 10배 둔해진다. drag를 0→5로 바꾸면 키를 떼는 순간 감속이 커져 미끄러짐이 줄어든다. 이 변화는 Update 프레임이 아니라 FixedUpdate 스텝에서 누적되므로, Time.fixedDeltaTime을 바꾸면 체감이 같이 변한다.
엔진 관점에서의 내부 동작
C#의 Rigidbody는 ‘래퍼’다. 실제 시뮬레이션 데이터(질량, 속도, 힘 누적 버퍼, 충돌 상태)는 네이티브(C++) 물리 엔진 쪽 메모리에 있다. C# 프로퍼티를 읽고 쓰는 순간, 내부 호출은 바인딩 레이어(IL2CPP/Mono + icall)를 통해 네이티브 오브젝트 핸들로 점프한다. 그래서 프로퍼티 접근이 ‘그냥 필드 읽기’처럼 0비용이 아니다.
Rigidbody 컴포넌트를 GameObject에 붙이는 순간, Unity는 네이티브 물리 월드에 대응 바디를 생성한다. 이때 콜라이더가 같이 있으면 shape도 생성되고, 질량/관성 텐서가 계산된다. Inspector에서 mass를 바꾸면 직렬화된 C# 값이 바뀌는 게 아니라, 네이티브 바디의 질량 속성이 갱신되고(필요 시 관성 재계산), 다음 시뮬레이션 스텝부터 반영된다.
PlayerLoop 관점에서 물리는 대략 이런 순서로 흘러간다. 입력 수집은 Update 이전에 수집되지만, 스크립트에서 Input.GetKey를 읽는 타이밍은 각 메시지 함수 호출 시점이다. FixedUpdate는 0~N번 호출될 수 있다(프레임이 느리면 여러 번). 그 다음 물리 시뮬레이션(Physics.Simulate)이 돌고, 충돌 콜백이 큐잉되며, 결과가 Transform에 동기화된다. Update는 그 이후라서, Update에서 Transform을 읽으면 ‘이번 프레임 물리 결과’를 보게 된다.
왜 Update와 FixedUpdate가 분리되었나. 렌더 프레임은 기기 성능에 따라 흔들린다. 물리 적분은 dt가 흔들리면 에너지 보존이 깨지고 튕김이 커진다. 그래서 Unity는 fixedDeltaTime(기본 0.02s)으로 물리를 고정 스텝으로 굴린다. 흔들리는 렌더 프레임 위에 고정된 물리 스텝을 얹는 구조라서, 메시지 함수도 분리됐다.
메모리 관점에서 Rigidbody는 Managed 객체(C#) + Native 객체(C++)의 쌍이다. C# 쪽은 컴포넌트 레퍼런스(Managed)가 있고, 네이티브 쪽은 실제 바디가 힙/풀에 있다. Destroy를 호출하면 Managed는 ‘파괴 예약’ 상태가 되고, 네이티브는 프레임 끝이나 안전한 지점에서 해제된다. 그래서 Destroy 직후에도 C# 레퍼런스가 null이 아닌 것처럼 보이는 순간이 생긴다(Unity의 가짜 null).
GetComponent가 느린 이유는 단순 탐색 비용 때문이다. GameObject는 네이티브 쪽에 컴포넌트 배열/리스트를 가지고 있고, GetComponent<T>()는 그 목록을 순회해 타입 매칭을 한다. 호출마다 네이티브로 넘어가고, 타입 체크/분기/마샬링 비용이 든다. Update에서 매 프레임 호출하면 0.01~0.1ms가 쌓이는 케이스가 흔하다(모바일에서 더 크게 튄다). Awake에서 캐싱하면 그 비용이 호출 횟수만큼 0으로 떨어진다.
1using UnityEngine;
2
3public class LoopProbe : MonoBehaviour
4{
5 private int _fixedCount;
6 private int _updateCount;
7
8 private void FixedUpdate()
9 {
10 _fixedCount++;
11 }
12
13 private void Update()
14 {
15 _updateCount++;
16 if (Time.frameCount % 60 == 0)
17 {
18 Debug.Log($"frame={Time.frameCount}, fixed={_fixedCount}, update={_updateCount}, fixedDelta={Time.fixedDeltaTime}");
19 _fixedCount = 0;
20 _updateCount = 0;
21 }
22 }
23}이 로그는 1초 동안 FixedUpdate가 몇 번 돌았는지 보여준다. 60fps 근처면 fixed가 대략 50번(0.02s 기준), update가 60번으로 찍힌다. 프레임이 떨어져 30fps가 되면 fixed가 50번에 가깝게 유지되면서 한 프레임에 FixedUpdate가 2번씩 몰리는 구간이 생긴다. 그때 Update에서 AddForce를 걸면 프레임마다 힘이 달라져 가속이 흔들린다.
1using UnityEngine;
2
3public class GetComponentCostDemo : MonoBehaviour
4{
5 private Rigidbody _cached;
6
7 private void Awake()
8 {
9 _cached = GetComponent<Rigidbody>();
10 }
11
12 private void Update()
13 {
14 // 의도적으로 두 경로를 번갈아 실행해 Profiler에서 차이를 만든다.
15 if ((Time.frameCount & 1) == 0)
16 {
17 var rb = GetComponent<Rigidbody>();
18 rb.drag = rb.drag; // 접근 자체가 네이티브 바인딩을 탄다.
19 }
20 else
21 {
22 _cached.drag = _cached.drag;
23 }
24 }
25}Profiler(Window → Analysis → Profiler)에서 CPU Usage를 켜고 Deep Profile 없이도 차이가 보인다. GetComponent 경로는 Script.Update에서 더 많은 시간이 찍히고, 내부적으로 Component.GetComponent 호출이 잡힌다. 캐시 경로는 그 항목이 사라진다. 처음에 나도 “프로퍼티 한 번 읽는 게 뭐가 문제냐”라고 생각했다가, 200개 오브젝트가 동시에 돌 때 0.2~1ms가 증발하는 걸 보고 습관을 바꿨다.
실습하기
1단계: 프로젝트와 물리 기본값 고정
Unity Hub → New project → 3D(Core) 템플릿을 선택한다. 버전은 2022 LTS 이상이면 된다. 프로젝트가 열리면 Edit → Project Settings → Time에서 Fixed Timestep이 0.02인지 확인한다. 여기 값을 바꾸면 모든 물리 체감이 바뀌므로 실습 중에는 고정한다.
Edit → Project Settings → Physics에서 Gravity가 (0, -9.81, 0)인지 확인한다. 이후 useGravity 토글의 효과가 눈으로 바로 보인다. 실습 중에는 Solver Iterations 같은 값은 건드리지 않는다. 초반에 여기서 손대면 ‘드래그 문제’인지 ‘솔버 문제’인지 구분이 안 된다.
1using UnityEngine;
2
3public class PhysicsSanityLogger : MonoBehaviour
4{
5 private void Start()
6 {
7 Debug.Log($"fixedDeltaTime={Time.fixedDeltaTime}");
8 Debug.Log($"gravity={Physics.gravity}");
9 Debug.Log($"defaultSolverIterations={Physics.defaultSolverIterations}");
10 }
11}이 코드는 설정이 흔들렸는지 확인하는 안전장치다. Play를 누르면 Console에 fixedDeltaTime과 gravity가 찍힌다. 팀 프로젝트에서 누군가 Time 설정을 바꿔 커밋해버리면, 같은 씬이 다른 PC에서 전혀 다른 느낌으로 움직인다. 이 로그가 있으면 원인 추적이 빨라진다.
2단계: 씬 구성(바닥, 경사, 테스트 오브젝트)
Hierarchy 우클릭 → 3D Object → Plane을 만든다. 이름을 Ground로 바꾼다. Transform에서 Position (0,0,0), Scale (5,1,5)로 둔다. Plane에는 기본으로 Mesh Collider가 붙어 있어 바닥 충돌이 된다.
Hierarchy 우클릭 → 3D Object → Cube를 만든다. 이름을 Slope로 바꾼다. Transform에서 Position (0,0.5,0), Rotation (0,0,20) 정도로 경사를 준다. Cube에는 Box Collider가 기본으로 붙는다. 이 경사에서 drag가 ‘미끄러짐’을 얼마나 줄이는지 눈으로 확인한다.
1using UnityEngine;
2
3public class SceneBuilderHint : MonoBehaviour
4{
5 [ContextMenu("Print Scene Setup")]
6 private void Print()
7 {
8 Debug.Log("Hierarchy: Ground(Plane), Slope(Cube rotated), PlayerBall(Sphere + Rigidbody)");
9 Debug.Log("Inspector: PlayerBall Rigidbody -> Use Gravity 체크, Drag 0~5, Mass 1~10");
10 Debug.Log("Play: 경사에서 굴러 내려가며 Drag 변화 체감");
11 }
12}ContextMenu를 실행하려면 컴포넌트 우측 톱니(또는 점 3개) 메뉴에서 Print Scene Setup을 클릭한다. 콘솔에 필요한 오브젝트 구성이 찍힌다. 초보자는 씬 구성 자체에서 빠뜨리는 게 많아서, 텍스트 체크포인트가 있으면 실수가 줄어든다.
3단계: Rigidbody 설정과 움직임 테스트
Hierarchy 우클릭 → 3D Object → Sphere를 만든다. 이름을 PlayerBall로 바꾼다. Transform Position을 (0, 3, 0) 정도로 올린다. Inspector에서 Add Component → Rigidbody를 추가한다. Rigidbody에서 Use Gravity 체크, Mass 1, Drag 0, Angular Drag 0.05, Interpolate는 None으로 둔다.
Project 창에서 Scripts 폴더를 만들고, RigidbodyBasics.cs를 생성해 PlayerBall에 붙인다. Play를 누르면 공이 떨어져 바닥에 닿고, 경사에 닿으면 굴러 내려간다. 이때 Inspector에서 Rigidbody Drag를 0→3→8로 바꿔가며, 키 입력이 없어도 경사에서 멈추는 지점을 확인한다. Drag를 과하게 올리면 공중에서도 속도가 줄어 ‘낙하가 끈적’해진다.
1using UnityEngine;
2
3public class RigidbodyTunerUI : MonoBehaviour
4{
5 public Rigidbody target;
6
7 private void Reset()
8 {
9 target = GetComponent<Rigidbody>();
10 }
11
12 private void OnGUI()
13 {
14 if (target == null) return;
15
16 GUI.Label(new Rect(10, 10, 400, 20), $"mass={target.mass:F1}, drag={target.drag:F2}, useGravity={target.useGravity}");
17 if (GUI.Button(new Rect(10, 40, 160, 30), "Toggle Gravity")) target.useGravity = !target.useGravity;
18 if (GUI.Button(new Rect(10, 80, 160, 30), "Mass x2")) target.mass *= 2f;
19 if (GUI.Button(new Rect(10, 120, 160, 30), "Drag +1")) target.drag += 1f;
20 if (GUI.Button(new Rect(10, 160, 160, 30), "Reset")) { target.mass = 1f; target.drag = 0f; target.useGravity = true; }
21 }
22}이 UI는 런타임에 값을 바꿔도 즉시 반영되는 걸 보여준다. Toggle Gravity를 누르면 다음 물리 스텝부터 중력 항이 빠지며, 공이 공중에서 ‘멈춘 채’ 관성만 유지한다(이미 가진 속도는 drag가 있으면 계속 줄어든다). Mass x2를 누르면 같은 AddForce를 줬을 때 가속이 둔해지는 게 체감된다. 값 변경이 C# 변수만 바뀌는 게 아니라 네이티브 바디 속성이 갱신되기 때문에 가능한 동작이다.
심화 활용
패턴 1: ‘움직임 감’은 drag가 아니라 가속/감속 모델로 만든다
drag는 공중에서도 동일하게 감쇠한다. 그래서 지상 마찰을 만들려고 drag를 올리면 점프 궤적이 망가진다. 실무에서는 drag를 낮게 유지하고, 지상일 때만 목표 속도에 맞춰 힘을 주거나, Physic Material의 마찰로 지상만 제어한다. drag는 ‘전체 유체 저항’에 가깝게 쓰는 편이 부작용이 적다.
1using UnityEngine;
2
3public class GroundOnlyDamping : MonoBehaviour
4{
5 public float moveForce = 30f;
6 public float groundDamp = 8f;
7 public LayerMask groundMask;
8
9 private Rigidbody _rb;
10
11 private void Awake()
12 {
13 _rb = GetComponent<Rigidbody>();
14 _rb.drag = 0.1f;
15 }
16
17 private void FixedUpdate()
18 {
19 Vector3 input = new Vector3(Input.GetAxisRaw("Horizontal"), 0f, Input.GetAxisRaw("Vertical"));
20 _rb.AddForce(input.normalized * moveForce, ForceMode.Force);
21
22 bool grounded = Physics.Raycast(transform.position, Vector3.down, 0.6f, groundMask, QueryTriggerInteraction.Ignore);
23 if (grounded)
24 {
25 Vector3 v = _rb.linearVelocity;
26 Vector3 lateral = new Vector3(v.x, 0f, v.z);
27 _rb.AddForce(-lateral * groundDamp, ForceMode.Acceleration);
28 }
29 }
30}이 코드를 쓰면 공중에서는 drag가 거의 없어 포물선이 자연스럽고, 지상에서는 수평 속도만 빨리 죽는다. Raycast는 네이티브 물리 월드에 질의하는 호출이라 비용이 있다. 그래서 groundMask를 좁혀야 한다. Layer를 하나 만들고(Ground), Plane과 Slope에만 할당하면 Raycast가 불필요한 콜라이더를 덜 검사한다.
패턴 2: FixedUpdate 입력 문제는 ‘입력 샘플링’으로 분리한다
FixedUpdate는 1프레임에 0~N번 실행된다. 여기서 Input.GetKeyDown 같은 ‘한 프레임 이벤트’를 읽으면 누락이 생긴다. 입력은 Update에서 샘플링하고, 물리 적용은 FixedUpdate에서 처리하는 구조가 흔한 이유가 여기 있다. 엔진이 루프를 분리해둔 이상, 사용자 코드도 분리하는 게 안정적이다.
1using UnityEngine;
2
3public class InputToPhysicsBridge : MonoBehaviour
4{
5 public float force = 20f;
6
7 private Rigidbody _rb;
8 private Vector3 _moveInput;
9 private bool _jumpPressed;
10
11 private void Awake()
12 {
13 _rb = GetComponent<Rigidbody>();
14 }
15
16 private void Update()
17 {
18 _moveInput = new Vector3(Input.GetAxisRaw("Horizontal"), 0f, Input.GetAxisRaw("Vertical"));
19 if (Input.GetKeyDown(KeyCode.Space)) _jumpPressed = true;
20 }
21
22 private void FixedUpdate()
23 {
24 _rb.AddForce(_moveInput.normalized * force, ForceMode.Force);
25 if (_jumpPressed)
26 {
27 _rb.AddForce(Vector3.up * 5f, ForceMode.Impulse);
28 _jumpPressed = false;
29 }
30 }
31}Space를 아주 짧게 눌러도 점프가 안정적으로 나간다. Update에서 눌림을 ‘래치’하고 FixedUpdate에서 소비하기 때문이다. 3시간 삽질 끝에 알아낸 건, 점프가 가끔 안 나가는 버그의 원인이 ‘내 코드 로직’이 아니라 FixedUpdate 호출 타이밍이었다는 점이다. Profiler에서 Physics.Simulate 앞뒤로 FixedUpdate가 몰리는 프레임을 찍어보면 재현이 된다.
한 문단 요약: mass·useGravity·drag는 C# 변수처럼 보이지만 네이티브 물리 바디의 적분 파라미터이며, FixedUpdate/Physics.Simulate 타이밍에 결합돼 움직임 감과 버그(입력 누락, 공중 감쇠, 프레임 의존 가속)를 만든다.
내 흑역사 1. 경사에서 멈추게 만들려고 drag를 12까지 올렸다가, 공중에서 낙하 속도가 거의 안 붙어서 ‘중력이 약한 게임’처럼 보였다. Console에는 아무 에러도 없고, Rigidbody 값은 정상이라 더 헷갈렸다. 원인은 drag가 선속도 전체를 감쇠해서 y축 속도까지 줄였기 때문이다. 해결은 drag를 낮추고, 지상에서만 수평 감쇠를 추가하는 방식으로 바꿨다.
내 흑역사 2. GetComponent<Rigidbody>()를 Update에서 매번 호출해도 PC에서는 티가 안 났다. 안드로이드 중저가에서 프레임이 58→45로 떨어지기 시작했고, Profiler에서 Scripts 항목이 비정상적으로 부풀어 있었다. Deep Profile을 켜니 GetComponent가 상위에 찍혔다. 해결은 Awake 캐싱 + 오브젝트 풀에서 재사용 시 캐시 레퍼런스를 유지하는 구조로 바꾸는 것이었다.
자주 하는 실수
실수 1: Update에서 Transform.position으로 이동시키면서 Rigidbody도 붙여둠
증상: 물체가 벽에 닿을 때 튕기거나, 바닥을 뚫거나, 충돌이 불안정하다. 경사에서 미끄러짐이 순간이동처럼 보이기도 한다.
원인: 네이티브 물리는 FixedUpdate 스텝에서 바디를 적분하고 결과를 Transform에 동기화한다. 그런데 Update에서 Transform을 덮어쓰면, 네이티브 바디는 ‘강제 텔레포트’로 인식하거나 다음 스텝에서 큰 보정이 걸린다. 충돌 연속성이 깨진다.
해결: 동적 Rigidbody는 AddForce, MovePosition(키네마틱), velocity/linearVelocity 같은 물리 API로만 이동시킨다. Transform 직접 변경은 순간이동(포탈)처럼 의도가 명확한 경우에만 쓴다.
실수 2: drag로 지상 마찰을 해결하려다 점프/낙하 감이 망가짐
증상: 공중에서 속도가 이상하게 줄어서 점프가 짧아지고, 낙하가 느리다. ‘중력 세기가 바뀐 것 같다’는 피드백이 나온다.
원인: drag는 선속도 전체에 감쇠를 건다. y축 속도도 줄어든다. 엔진 내부에서는 스텝마다 v *= (1 - k*dt) 같은 형태로 감쇠가 누적되므로, 공중에서도 계속 제동이 걸린다.
해결: drag는 낮게 두고, 지상에서만 수평 감쇠를 추가하거나 Physic Material의 마찰을 쓴다. 지상 판정은 Raycast/Collision으로 만들되 레이어로 범위를 제한한다.
실수 3: FixedUpdate에서 GetKeyDown을 읽어 점프가 가끔 안 나감
증상: Space를 눌렀는데 점프가 10번 중 1번 정도 안 나간다. 프레임이 떨어질수록 더 자주 발생한다.
원인: GetKeyDown은 ‘렌더 프레임 기준 1프레임’ 이벤트다. FixedUpdate는 물리 스텝 기준이라 호출 타이밍이 어긋나면 눌림 프레임을 지나쳐버린다. PlayerLoop 구조 때문에 생기는 타이밍 버그다.
해결: Update에서 입력을 샘플링해 bool로 래치하고, FixedUpdate에서 소비한다. 또는 Input System의 이벤트 기반 입력을 사용해 물리 쪽으로 전달한다.
실수 4: mass를 키우면 더 빨리 떨어질 거라 기대함
증상: 무거운 물체를 만들려고 mass를 50으로 올렸는데 낙하 속도가 가벼운 물체와 똑같다. ‘mass가 적용이 안 된다’고 느낀다.
원인: 중력은 가속도 g로 적용된다. 힘 관점에서는 F = m*g지만, 가속도는 a = F/m이라서 m이 약분되어 a=g가 된다. 네이티브 적분에서도 중력은 바디 질량과 무관하게 같은 가속도로 더해진다.
해결: 낙하 감을 바꾸려면 Physics.gravity를 바꾸거나(전역 영향), 개별 바디에 추가 가속(ForceMode.Acceleration)으로 ‘추가 중력’을 더한다. mass는 충돌 반응, 힘에 대한 가속도, 관성에 주로 영향을 준다.
실수 5: Rigidbody 값을 매 프레임 Inspector처럼 계속 세팅함
증상: drag를 런타임에서 바꾸는 기능을 넣었는데, 특정 프레임에서 값이 되돌아가거나, 네트워크 동기화 후 흔들린다. Profiler에서 Scripts가 튄다.
원인: Update에서 매번 rb.mass = ... 같은 세팅을 반복하면, 매 프레임 네이티브 바디 속성 갱신 호출이 발생한다. 내부적으로 잠금/동기화 포인트가 생길 수 있고, 불필요한 바인딩 호출이 누적된다.
해결: 값 변경이 필요한 순간에만 세팅한다. 튜닝 UI는 버튼/슬라이더 이벤트에서만 적용한다. 네트워크 수신도 ‘변경된 경우에만’ 반영한다.
1using UnityEngine;
2
3public class ChangeOnlyWhenDifferent : MonoBehaviour
4{
5 public float desiredDrag = 0f;
6
7 private Rigidbody _rb;
8 private float _lastDrag;
9
10 private void Awake()
11 {
12 _rb = GetComponent<Rigidbody>();
13 _lastDrag = _rb.drag;
14 }
15
16 private void Update()
17 {
18 if (Mathf.Abs(desiredDrag - _lastDrag) > 0.001f)
19 {
20 _rb.drag = desiredDrag;
21 _lastDrag = desiredDrag;
22 Debug.Log($"drag changed -> {desiredDrag}");
23 }
24 }
25}콘솔에 drag changed가 ‘변경 시점’에만 찍히면 의도대로다. Update에서 매 프레임 세팅하던 코드를 이런 식으로 바꾸면, 네이티브 바인딩 호출 횟수가 급감한다. 특히 수백 개 오브젝트가 동시에 튜닝 값을 들고 있을 때 차이가 커진다.
성능 최적화 체크리스트
- Profiler(Window → Analysis → Profiler)에서 CPU Usage 모듈로 Scripts와 Physics.Simulate 시간을 분리해서 본다
- Update에서 AddForce/velocity 세팅을 하지 않고 FixedUpdate로 옮긴다(프레임레이트 의존 가속 제거)
- GetComponent<T>()는 Awake/Start에서 캐싱하고, Update/FixedUpdate 루프에서 반복 호출하지 않는다
- drag를 지상 마찰 대용으로 과하게 올리지 않고, 지상 판정 후 수평 감쇠 또는 Physic Material 마찰로 분리한다
- Raycast/Overlap 같은 물리 질의는 LayerMask로 범위를 줄이고 QueryTriggerInteraction 옵션을 명시한다
- Time.fixedDeltaTime 변경은 ‘게임 전체 물리 감’이 바뀌는 작업으로 취급하고, 프로젝트 초기에만 결정한다
- Rigidbody Interpolate는 카메라/플레이어 등 ‘렌더 흔들림’이 보이는 대상에만 선택적으로 켠다(전체 적용 금지)
- Collision Detection(Discrete/Continuous)은 빠른 물체에만 올리고, 필요 없는 오브젝트는 Discrete로 유지한다
- Instantiate/Destroy로 Rigidbody를 매 프레임 만들지 않고 오브젝트 풀을 사용해 네이티브 물리 객체 생성/해제를 줄인다
- 런타임에서 mass/drag/useGravity를 매 프레임 재설정하지 않고 ‘값이 바뀐 경우에만’ 네이티브로 반영한다
- 입력은 Update에서 샘플링하고 FixedUpdate에서 소비한다(GetKeyDown 누락 방지)
- GC Alloc 컬럼을 확인하고, OnGUI/문자열 보간 로그를 개발 빌드에서만 켠다
자주 묻는 질문
mass를 올렸는데 왜 떨어지는 속도는 그대로인가?
자유낙하에서 가속도는 g로 고정된다. 물리 엔진은 각 바디에 중력 가속도를 더해 속도를 적분한다. 힘 관점으로 보면 F=m*g이지만, 가속도는 a=F/m이라서 m이 약분된다. 그래서 mass가 달라도 낙하 가속도는 같다. 낙하 감을 바꾸고 싶다면 Physics.gravity를 바꾸거나(전역), 개별 오브젝트에 ForceMode.Acceleration으로 추가 가속을 더해 ‘추가 중력’을 만든다. 다음 학습 키워드는 ForceMode 차이(Force/Acceleration/Impulse/VelocityChange)와 적분(dt)이다.
drag를 올리면 왜 공중에서도 느려지나? 지상 마찰만 걸고 싶다
drag는 선속도 전체에 감쇠를 건다. 엔진 내부에서는 물리 스텝마다 속도에 감쇠 항을 적용하고 적분한다. 공중/지상 구분 없이 v의 크기가 줄어들기 때문에, y축 낙하 속도도 함께 줄어 점프와 낙하가 끈적해진다. 지상 마찰만 원하면 두 갈래가 있다. (1) Physic Material의 friction으로 접촉면에서만 저항을 만들기 (2) 지상 판정 후 수평 성분(v.x, v.z)만 감쇠하는 힘을 추가하기. 다음 학습 키워드는 Physic Material의 Dynamic/Static Friction, 그리고 지상 판정(Raycast vs Collision)이다.
왜 Update와 FixedUpdate가 분리되어 있나?
렌더 프레임은 기기 성능에 따라 dt가 흔들린다. 물리 적분은 dt가 흔들리면 충돌 해결이 불안정해지고 에너지가 튀어 움직임 감이 깨진다. 그래서 Unity는 fixedDeltaTime으로 고정 스텝 물리를 돌리고, 그 스텝마다 FixedUpdate를 호출한다. 한 프레임에 FixedUpdate가 0번일 수도, 2~3번일 수도 있다. 입력은 Update에서 더 촘촘히 샘플링하고, 물리 적용은 FixedUpdate에서 일정한 dt로 처리하는 구조가 안정적이다. 다음 학습 키워드는 PlayerLoop, Physics.Simulate, fixedDeltaTime과 maximumDeltaTime의 관계다.
Rigidbody 프로퍼티를 읽고 쓰는 게 왜 비싼가? 그냥 C# 필드 아닌가?
Rigidbody는 Managed(C#) 객체와 Native(C++) 물리 바디가 짝을 이룬다. mass/drag 같은 프로퍼티 접근은 C# 필드 접근이 아니라 네이티브 바인딩 호출(icall)을 탄다. 호출마다 네이티브 오브젝트 핸들로 점프하고, 내부에서 값을 읽거나 갱신한다. 많은 오브젝트가 매 프레임 반복 접근하면 바인딩 오버헤드가 쌓인다. 그래서 GetComponent 캐싱과 동일하게, Rigidbody 참조도 캐싱하고 값 변경은 ‘필요한 순간’에만 수행하는 패턴이 중요하다. 다음 학습 키워드는 Unity의 Native/Managed 브리지, 그리고 Profiler에서 Scripts vs Physics 구분이다.
GetComponent를 캐싱하면 왜 빨라지나? 어떤 수준으로 차이가 나나?
GetComponent<T>()는 해당 GameObject의 컴포넌트 목록을 네이티브에서 순회해 타입 매칭을 한다. 호출마다 네이티브로 넘어가며 탐색 비용이 발생한다. 오브젝트 하나면 티가 안 나지만, 200개가 Update에서 매 프레임 호출하면 모바일 기준 0.2~1ms가 사라지는 케이스가 흔하다(프로젝트 구조/컴포넌트 수에 따라 변동). 캐싱은 탐색을 1회로 줄여 이후 프레임 비용을 없앤다. 다음 학습 키워드는 GetComponentInChildren/Find의 비용, 그리고 Awake/OnEnable에서 캐싱하는 생명주기다.
FixedUpdate에서 AddForce를 쓰는데도 움직임이 프레임마다 달라 보인다
원인은 대개 두 가지다. (1) 카메라가 Update에서 따라가며 물리 오브젝트는 FixedUpdate에서 움직여 렌더 사이에 ‘계단’이 보이는 경우 (2) fixedDeltaTime 대비 렌더 프레임이 크게 흔들려 한 프레임에 FixedUpdate가 여러 번 몰리는 경우다. (1)은 Rigidbody Interpolate를 켜거나 카메라 추적을 LateUpdate에서 보간해 해결한다. (2)는 물리 연산량을 줄이거나, maximumDeltaTime 설정을 점검해 폭주를 막는다. 다음 학습 키워드는 Interpolation, 카메라 follow의 Update/LateUpdate 선택, 그리고 Time 설정이다.
useGravity를 껐는데도 오브젝트가 아래로 내려간다
useGravity는 ‘중력 가속도 항’을 끄는 스위치일 뿐, 이미 가진 속도나 다른 힘까지 막지 않는다. 예를 들어 이전에 아래 방향 속도가 있었다면 그대로 유지되며, drag가 있으면 서서히 줄어든다. 또 다른 스크립트가 AddForce(Vector3.down ...)를 주거나, CharacterController/Transform 이동으로 아래로 끌고 갈 수도 있다. 확인 순서는 (1) Rigidbody의 linearVelocity를 로그로 찍기 (2) 씬에서 해당 오브젝트에 붙은 스크립트가 힘을 주는지 검색하기 (3) Constraints로 Y를 잠궈서 외력인지 확인하기다. 다음 학습 키워드는 velocity/linearVelocity, 외력 디버깅, 그리고 Constraints다.