개요
지난 편은 하루를 통째로 기획서에 쓴 이야기였다. 마지막 줄에 이렇게 적어뒀다. 문서만 예쁘고 구현에서 다 무너질 수도 있으니 그건 다음 편에서 알게 될 것 같다고.
그 다음 편이 오늘이다. 개발 1일차, 드디어 코드를 썼다.
만든 건 딱 하나. PlayerMovement.cs 한 파일이다. WASD로 8방향 이동하고, Space로 대시하고, 대시 중에 벽을 만나면 벽 앞에서 멈춘다. 문장으로 쓰면 세 줄인데 여기서만 버그를 세 번 만났다.
제일 오래 붙잡은 건 대시를 눌렀는데 아무 일도 안 일어나는 버그였다. 그리고 이 버그에서 내가 처음 세운 가설은 방향은 맞았는데 범인은 틀렸다. 이게 오늘의 알맹이다.
이번 글이 답할 질문. 나는 왜 원인을 절반만 맞혔을까?
1. 이동 — if 16개 없이 8방향 만들기
첫 관문은 어이없게도 코드가 아니라 설정이었다. Input.GetKey를 처음 쓰자마자 콘솔이 빨갛게 물들었다.
InvalidOperationException. Unity에는 입력을 받는 방식이 옛날 것(레거시 Input)과 새 것(Input System) 두 가지가 있는데, 프로젝트 설정이 새 것 전용으로 되어 있으면 옛날 방식이 아예 막힌다. Player Settings의 Active Input Handling을 Both로 바꾸고 에디터를 재시작하니 풀렸다.
( 재시작을 안 하면 설정만 바뀌고 그대로다.. 이걸 몰라서 한참 헤맸다 )
이제 진짜 이동이다. 키는 W/A/S/D 네 개. 처음엔 이렇게 접근했다. W만 누르면 위, W와 D를 같이 누르면 오른쪽 위, 그러면 조합이 몇 개지?
네 개의 키가 각각 눌림/안 눌림이니 2의 4제곱, 16가지다. if를 16개 쓸 순 없다.
분기가 조합 폭발하면, 그건 조건문이 아니라 연산으로 풀라는 신호다.
축 하나에 키가 두 개씩 물려 있다고 보면 된다. 가로축은 D가 +1, A가 -1. 세로축은 W가 +1, S가 -1. 그럼 축당 한 줄로 끝난다.
float x = (Input.GetKey(KeyCode.D) ? 1f : 0f) - (Input.GetKey(KeyCode.A) ? 1f : 0f);
float y = (Input.GetKey(KeyCode.W) ? 1f : 0f) - (Input.GetKey(KeyCode.S) ? 1f : 0f);
_moveInput = new Vector2(x, y).normalized;
대각선을 따로 처리한 곳이 한 줄도 없는데 대각선이 나온다. W와 D를 같이 누르면 x도 1, y도 1이라 저절로 (1, 1)이 된다.
여기서 C 계열을 하다 온 사람이 걸리는 지점이 하나 있다. C나 C++이면 bool이 그냥 0과 1이라 캐스트해서 빼면 되는데, C#은 bool을 int로 캐스트할 수 없다. 그래서 삼항 연산자로 풀어 쓴 것이다.
마지막 .normalized도 이유가 있다. (1, 1)의 길이는 루트 2, 약 1.41이다. 그대로 두면 대각선으로 갈 때만 40% 빨라진다. 정규화는 방향은 그대로 두고 길이만 1로 맞춰주는 계산이다.
영벡터, 그러니까 아무 키도 안 눌러 (0, 0)일 때 정규화하면 0으로 나누는 거 아닌가 싶었는데 Unity는 예외를 던지지 않고 그냥 (0, 0)을 돌려준다. 그래서 분기 없이 항상 불러도 된다.
2. 두 개의 시계 — Update와 FixedUpdate
Unity에는 매 프레임 도는 Update와, 물리 계산에 맞춰 도는 FixedUpdate가 따로 있다. 나는 이 둘을 나누는 이유가 “컴퓨터 성능 따라 캐릭터 속도가 달라지지 않게 하려고”인 줄 알았다.
아니었다. 그건 Time.deltaTime(직전 프레임에서 지금까지 걸린 시간)을 곱해주는 것만으로 이미 해결된다.
진짜 이유는 따로 있다. 물리 계산기가 FixedUpdate 주기로 돌기 때문이다. 물리 계산기는 매 주기마다 “이 물체들이 어디서 부딪혔고 어떻게 밀려나야 하는지”를 정리한다. 그런데 Update에서 위치를 직접 건드리면, 계산기가 방금 정리해둔 결과를 덮어쓴다. 캐릭터가 벽에서 덜덜 떨거나 그대로 통과해버린다.
그래서 오늘 만든 파일은 이렇게 나뉘어 있다.
| 함수 | 하는 일 |
|---|---|
Update |
입력만 읽는다 (WASD 접기, 정규화, 대시 키 확인) |
FixedUpdate |
물리만 적용한다 (속도 대입, 쿨다운 차감, 대시 raycast) |
그리고 여기서 제일 중요한 사실 하나. FixedUpdate는 고정된 시간마다 꼬박꼬박 호출되지 않는다.
엔진은 흘러간 시간을 통에 계속 붓는다. 통에 담긴 값이 Fixed Timestep(기본 0.02초, 초당 50회)을 넘으면 그만큼 덜어내면서 FixedUpdate를 부른다. 그러니까 한 프레임에 0번일 수도 있고, 2번 이상일 수도 있다.
FixedUpdate는 “매 0.02초마다”가 아니라 “쌓인 시간을 소진할 때마다” 돈다.
더 쉬운 비유로
시계 하나 : 눈이 보는 속도 — 화면이 그려지는 만큼 돈다
시계 둘 : 몸이 움직이는 속도 — 정해진 양의 시간이 쌓일 때마다 돈다
둘은 절대 같이 안 돈다
버스 정류장에 서 있다고 해보자. 나는 1초에 한 번씩 고개를 들어 버스가 왔나 본다(Update). 버스는 5분에 한 대씩 온다(FixedUpdate). 내가 고개를 300번 드는 동안 버스는 한 대 온다. 그런데 어떤 날은 차가 막혀서 10분에 한 대 오고, 어떤 날은 밀렸던 두 대가 한꺼번에 온다.
여기서 내가 “고개 들었을 때 손에 든 표를 꺼냈다가 버스가 없으면 도로 집어넣는다”고 정해두면 어떻게 될까. 그 표는 영영 전달되지 않는다.
이게 정확히 다음 섹션에서 일어난 일이다.
3. 대시 — 입력이 사라졌다
증상은 단순했다. Space를 눌러도 대시가 안 나간다. 그런데 매번 안 나가는 게 아니라 될 때도 있었다. 열 번 누르면 서너 번 나갔다.
처음 세운 가설은 이랬다.
FixedUpdate타이밍 문제일 것이다. 호출이 걸러지는 프레임에 키를 누르면 못 읽는 게 아닐까?
Input.GetKeyDown은 Update 주기 기준으로 딱 한 프레임만 true 인 함수다. 그러니 FixedUpdate에서 직접 읽으면 안 되고, Update에서 받아 깃발에 세워둔 다음 FixedUpdate에서 꺼내 써야 한다. 여기까진 이미 그렇게 짜뒀었다.
// Update
_dashInput = Input.GetKeyDown(KeyCode.LeftShift);
// FixedUpdate
if (_dashInput) { Dash(); _dashInput = false; }
깃발도 있고 Update에서 세우고 FixedUpdate에서 소비한다. 그런데도 안 됐다.
범인은 타이밍이 아니라 첫 줄의 등호였다. 저건 대입이다. 키를 안 누른 프레임이 오면 Input.GetKeyDown이 false를 돌려주고, 그 false가 방금 세워둔 true를 덮어쓴다.
FixedUpdate가 그 사이에 한 번이라도 돌았으면 대시는 나간다. 그래서 서너 번은 됐던 거다. 안 돈 프레임이 끼면 다음 Update가 깃발을 지워버린다.
// Update
_dashInput = _dashInput || Input.GetKeyDown(KeyCode.LeftShift);
// FixedUpdate
if (_dashInput) { Dash(); _dashInput = false; }
|| 하나 붙였다. 이제 true는 Update가 세우고, false는 FixedUpdate만 내린다.
깃발의 핵심 성질은 값이 아니라 세운 쪽과 내리는 쪽이 다르다는 것이다.
그런데 이동은 왜 멀쩡했을까. 이동은 _rb.linearVelocity = _moveInput * moveSpeed; 한 줄인데, 이게 “이번에 이만큼 가라”가 아니라 “지금 속도가 이거다”를 덮어쓰는 명령이라 두 번 실행돼도 한 번 걸러져도 결과가 같다. 대시는 반대로 “지금 한 번 튀어라”라서 걸러지면 그 한 번이 통째로 사라진다.
( velocity는 Unity 6에서 obsolete가 됐고 linearVelocity로 바뀌었다. 축 하나만 만지는 linearVelocityX / linearVelocityY도 같이 생겼다 )
고친 뒤가 정말 고쳐진 건지 확인하는 방법도 하나 배웠다. Fixed Timestep을 0.02에서 0.1로 올리면 FixedUpdate가 훨씬 드물게 돌아서 이 버그가 거의 매번 재현된다. 버그를 일부러 잘 터지게 만들어두고 고치는 것이다.
4. Raycast — 반환 타입을 잘못 알았다
대시가 나가기 시작하니 다음 문제가 나왔다. 벽을 뚫고 나간다.
대시는 순간이동에 가까워서 물리 계산기가 중간을 못 잡는다. 그래서 이동 전에 레이저를 한 번 쏴서 가는 길에 벽이 있는지 미리 보는 방식(raycast)을 썼다. 그런데 첫 줄부터 컴파일이 안 됐다.
나는 Physics2D.Raycast가 맞은 지점의 좌표를 준다고 생각했다. 아니었다. 맞은 지점, 맞은 물체, 표면의 방향, 거리까지 한 묶음으로 담은 RaycastHit2D를 준다.
그럼 아무것도 안 맞았을 땐 뭘 주나? 여기가 함정이었다.
RaycastHit2D hit = Physics2D.Raycast(origin, dir, dashDistance, wallLayer);
if (hit.point != null) { /* 항상 여기로 들어온다 */ }
RaycastHit2D는 struct(값 타입)라 null이 될 수 없다. 그러니 hit.point != null은 언제나 참이다. 그런데 Vector2가 자기만의 비교 연산자를 갖고 있어서 컴파일은 그냥 통과한다. 틀린 코드인데 빨간 줄이 안 그어지니 더 위험하다.
아무것도 안 맞으면 hit.point는 (0, 0)이다. 그래서 대시를 쓸 때마다 캐릭터가 월드 원점으로 순간이동했다.
판정은 반드시 이렇게 해야 한다.
if (hit.collider != null)
transform.position = hit.point + hit.normal * 0.5f;
hit.collider는 참조 타입이라 안 맞으면 진짜로 null이다.
뒤에 붙은 hit.normal * 0.5f도 필요했다. 맞은 지점은 벽 표면이라 거기에 캐릭터 중심을 놓으면 몸통 절반이 벽에 박힌다. normal은 벽 표면이 바깥을 향하는 방향이라, 그쪽으로 반 칸 물러나면 딱 붙어서 선다.
struct는
null이 안 되니, 실패를 확인하려면 참조 타입인 필드를 봐야 한다.
( 더 정확한 방법은 점이 아니라 캐릭터 크기의 도형을 쏘는 Physics2D.CapsuleCast다. 파묻히는 양이 반환값에 이미 반영돼서 따로 물러날 필요가 없다. 이번엔 안 썼고 다음에 바꿔볼 생각이다 )
5. 인스펙터가 알려준 것
세 번째 버그는 raycast가 아무것도 못 맞히는 문제였다. 벽에 대고 대시해도 그냥 지나간다.
원인을 못 찾다가 인스펙터를 열어보고 바로 알았다.
Wall Layer: Nothing. 어떤 레이어를 벽으로 볼지 지정을 안 해둔 것이다. 아무 레이어도 안 보니 당연히 아무것도 안 맞는다.
여기서 처음에 큰 오해를 하나 했었다. 나는 벽을 6번 레이어에 넣어뒀으니 코드에 6을 넘기면 될 줄 알았다. 그런데 LayerMask는 몇 번째냐가 아니라 32개 스위치를 켜고 끈 값이다.
| 넘긴 값 | 실제 의미 |
|---|---|
6 |
이진수로 000110 이라 1번과 2번 레이어
|
1 << 6 |
이진수로 1000000 이라 6번 레이어 |
숫자 6은 “여섯 번째”가 아니라 “2번 스위치와 1번 스위치가 켜짐”으로 읽힌다. 변환은 1 << 인덱스.
다만 실무에선 이 계산을 직접 안 한다. [SerializeField] private LayerMask wallLayer; 로 선언해두면 인스펙터에 체크박스 드롭다운이 생겨서 마우스로 고르면 된다. 나는 그렇게 해놓고 정작 고르질 않아서 Nothing이었던 거다..
같은 캡처에 하나 더 보인다. Dash Cooldown Re...가 -2.259998. 쿨다운을 매 물리 스텝마다 깎기만 하고 0에서 안 멈추게 짜놔서 계속 음수로 내려가고 있었다. 동작에는 문제가 없지만( 어차피 0 이하면 대시 가능이니 ) 디버깅용으로 내부 값을 인스펙터에 노출한 건 걷어낼 생각이다.
값을 못 찾겠으면 코드를 더 보지 말고 인스펙터를 열어라. 오늘 두 가지를 한 장으로 잡았다.
참고로 시간을 재는 방식은 두 가지를 나눠 썼다.
| 방식 | 오차 | 어디에 |
|---|---|---|
차감식 remaining -= Time.fixedDeltaTime
|
스텝당 최대 0.02초, 리셋되면 누적은 안 됨 | 대시 쿨다운 |
기준시각식 Time.time >= last + cooldown
|
매번 새로 계산해서 누적 자체가 없음 | 메인 타이머 5분 / 보스 60초 |
짧고 자주 리셋되는 건 차감식이 편하고, 5분처럼 긴 건 오차가 쌓이면 안 되니 기준시각식이 맞다. 다만 Time.time은 게임을 멈추면(timeScale) 같이 멈춘다. 기획서에 “보스전 동안 메인 타이머 정지”를 써뒀으니 나중에 이 성질을 다시 만날 것 같다.
6. 예전에 배운 게 여기서 겹쳤다 — 서버 tick과 클라 프레임
두 개의 시계 이야기를 정리하다가 익숙한 느낌이 들었다.
게임 서버 부트캠프에서 클라이언트끼리 이동을 맞추는 걸 배울 때였다. 서버는 일정 주기로만 위치를 정리해서 뿌리는데, 클라이언트 화면은 그보다 훨씬 자주 그려진다. 그 사이를 그냥 두면 캐릭터가 뚝뚝 끊겨 보이니까, 받은 두 지점 사이를 선형보간으로 메워서 프레임 수와 상관없이 부드럽게 보이게 한다고 배웠다.
그때는 이걸 네트워크 얘기라고만 생각했다. 그런데 오늘 보니 같은 구조가 컴퓨터 한 대 안에서도 똑같이 있다.
| 그때 (네트워크) | 오늘 (Unity 한 대) |
|---|---|
| 서버 tick — 드물게, 일정 주기 |
FixedUpdate — 드물게, 누적 시간 기준 |
| 클라 렌더 프레임 — 자주 |
Update — 자주, 화면 그리는 만큼 |
| 사이를 선형보간으로 메움 | Rigidbody의 Interpolate 옵션이 같은 일을 해줌 |
다른 점도 있다. 네트워크는 패킷이 늦게 오거나 아예 안 오거나 순서가 바뀌는데, 한 대 안에서는 그런 일이 없다. 대신 오늘 겪은 것처럼 호출 자체가 걸러지는 문제가 생긴다.
주기가 다른 두 축이 만나면 항상 같은 질문이 나온다. 빠른 쪽이 만든 값을 느린 쪽이 어떻게 놓치지 않고 받아갈 것인가.
정리
-
8방향은 조합이 아니라 연산이다. 축마다
(양수키 ? 1 : 0) - (음수키 ? 1 : 0)한 줄이면 대각선은 저절로 나온다. C#은bool을int로 캐스트할 수 없어 삼항으로 쓴다. -
Update와FixedUpdate를 나누는 이유는 프레임 독립이 아니다. 물리 계산기가FixedUpdate주기로 도니까,Update에서 위치를 만지면 결과를 덮어쓴다. -
FixedUpdate는 고정 주기 호출이 아니다. 쌓인 시간을 소진하는 방식이라 한 프레임에 0번일 수도, 2번 이상일 수도 있다. -
가설은 절반만 맞았다. 타이밍이 조건을 만든 건 맞지만, 값을 지운 범인은
Update의 대입문이었다. -
깃발은 세운 쪽과 내리는 쪽이 달라야 한다. 등호를
= 기존값 ||로 바꾸는 게 수정 전부였다. - 이동은 멀쩡했던 이유는 속도 대입이 몇 번 실행되든 결과가 같은 연산이라서다. 대시는 한 번짜리라 걸러지면 사라진다.
-
Physics2D.Raycast는 좌표가 아니라 struct를 준다. struct는null이 못 되니 판정은hit.collider != null로. -
LayerMask는 인덱스가 아니라 비트마스크.
6이 아니라1 << 6. 실무에선[SerializeField]로 인스펙터에서 고른다. - 버그 재현율은 조절할 수 있다. Fixed Timestep을 0.1로 올리면 타이밍 버그가 거의 매번 나온다.
참고 자료
- Unity 6000.0 Scripting API - Rigidbody2D.linearVelocity
- Unity 6000.0 Scripting API - Physics2D.Raycast
- 지난 편 - 유니티 학습 프로젝트 < 1 >
한줄 평
- 원인을 절반 맞히면 고쳐지지 않는다는 걸 오늘 세 번 배웠다..