Sprite Flight (2) 마우스 조작, 충돌, 점수 UI
이번 글에서는 아래 내용을 정리한다.
- 4편: 부모-자식으로 플레이어 만들기, Input System으로 마우스 읽기, 화면 좌표와 월드 좌표,
normalized,AddForce,OnCollisionEnter2D - 5편:
Time.deltaTime으로 생존 시간 재기,Mathf.FloorToInt, UI Toolkit으로 점수 라벨 띄우기 - 따라 하다가 걸린 질문들. Rigidbody는 왜 부모에만 붙이는가, 매 프레임 계산은 부하가 아닌가, GC가 돌면
new를 못 쓰는 것 아닌가, UI가 Scene 뷰에 안 보이는 이유
1편에서 이어진다.
4편: 플레이어 조종
부모-자식 구조로 우주선 만들기
빈 GameObject “Player"를 만들고 Position을 (0, -4, 0)으로 둔다. 그 자식으로 2D Object > Sprites > Triangle을 넣고, 방향을 알아보기 쉽게 사각형이나 캡슐을 하나 더 자식으로 붙여 우주선 모양을 만든다.
물리 컴포넌트는 이렇게 나눈다.
- 부모 Player에
Rigidbody 2D. Gravity Scale은 0. - 자식 스프라이트들에
Polygon Collider 2D.
여기서 질문이 생겼다. 왜 Rigidbody는 부모에만 붙이고 콜라이더는 자식에 붙이는가.
Rigidbody2D는 자식에 붙은 콜라이더를 전부 자기 것으로 흡수한다. 물리 엔진은 위 구조를 “콜라이더 두 개짜리 물체 하나"로 본다. 자식이 밀리면 부모 전체가 밀린다. 서버로 치면 엔티티는 하나인데 히트박스가 머리, 몸통으로 여러 개인 구조다.
Player (Rigidbody2D) ← 물리 계산의 단위. 위치, 속도, 질량
├─ Triangle (PolygonCollider2D) ← 모양 1
└─ Square (PolygonCollider2D) ← 모양 2그래서 규칙이 갈린다.
- Rigidbody2D는 부모에만. 자식에도 붙이면 자식이 별개의 물리 물체가 되어 부모와 따로 논다.
- 콜라이더는 모양이 있는 곳에.
PolygonCollider2D는 그 오브젝트의 스프라이트 외곽선을 따라 모양을 잡으므로 스프라이트와 같은 오브젝트에 있어야 한다.
부모는 물리(위치, 속도), 자식은 외형(그림, 회전, 애니메이션)으로 역할이 나뉜다. 자식을 빙글빙글 돌려도 부모의 물리 이동에는 영향이 없고, 그림을 바꿔도 부모의 스크립트와 Rigidbody 설정은 그대로다. 나중에 아트 담당이 외형을 바꿀 때 자식만 손대면 된다.
그러면 관절처럼 여러 부위가 따로 움직여야 하면 Rigidbody가 여러 개일 수 있는가. 있다. 부위마다 Rigidbody2D를 두고 사이를 HingeJoint2D, DistanceJoint2D 같은 Joint2D로 연결한다. 랙돌, 체인, 매달린 간판이 이 구조다. 다만 캐릭터의 걷기 같은 관절 움직임은 물리가 아니라 Animator가 각 부위의 Transform을 프레임마다 정해진 값으로 돌리는 것이라 Rigidbody는 여전히 부모에 하나다.
| 원하는 것 | 방법 |
|---|---|
| 한 덩어리로 움직이는 물체, 판정만 여러 개 | Rigidbody 하나 + 자식 콜라이더 여러 개 |
| 정해진 대로 움직이는 관절 (걷기, 공격 모션) | Rigidbody 하나 + Animator |
| 물리로 흔들리는 관절 (랙돌, 줄) | Rigidbody 여러 개 + Joint2D |
마우스 클릭 읽기
Scripts 폴더에 PlayerController를 만들어 Player에 붙인다. Update에 이것을 넣는다.
if (Mouse.current.leftButton.isPressed)
{
Debug.Log("Mouse was pressed");
}Mouse가 빨간 줄이면 파일 맨 위에 using UnityEngine.InputSystem;을 추가한다. Mouse는 Input System 패키지의 클래스라 using UnityEngine;만으로는 못 찾는다.
error CS0103: The name 'Mouse' does not exist in the current contextMouse.current: 지금 연결된 마우스 장치..leftButton.isPressed: 눌려 있는 동안 매 프레임 true. 누른 순간 한 번만 원하면wasPressedThisFrame. 학원 때의Input.GetMouseButton(0)과Input.GetMouseButtonDown(0)의 관계다.
이것이 Unity 6의 새 방식이다. 옛 Input.GetKey, Input.GetAxis는 Input System만 켠 프로젝트에서는 예외가 난다. 그리고 Mouse.current는 마우스가 없으면 null이다. 모바일에서는 .leftButton에서 NullReferenceException이 난다. 7편에서 이 문제를 InputAction으로 푼다.
화면 좌표와 월드 좌표
Vector3 mousePos = Mouse.current.position.value; // (가)
Vector3 mousePos = Camera.main.ScreenToWorldPoint(Mouse.current.position.value); // (나)(가)는 화면 픽셀 좌표, (나)는 게임 월드 좌표다.
| 화면 좌표 | 월드 좌표 | |
|---|---|---|
| 단위 | 픽셀 | 유닛 |
| 원점 | 화면 왼쪽 아래 | 씬의 (0, 0). 카메라와 무관 |
| 범위 | 1920×1080이면 x는 0~1920 | 카메라 Size 7.5면 y는 -7.5~7.5 |
| 누가 쓰나 | 마우스, 터치, UI | GameObject의 transform.position |
(가)를 그대로 transform.position에 넣으면 플레이어가 월드 (960, 540)으로 날아간다. ScreenToWorldPoint가 “화면의 이 픽셀이 월드에서 어디인가"를 카메라 기준으로 변환한다. 서버로 치면 클라이언트가 보낸 화면 클릭 좌표를 존의 월드 좌표로 바꾸는 것이다.
2D에서 걸리는 함정이 하나 있다. ScreenToWorldPoint는 Z도 계산하는데, 입력의 Z가 0이면 카메라 자체의 Z(2D는 보통 -10)가 결과로 나온다. 이 값을 그대로 위치에 넣으면 플레이어가 카메라 뒤로 가서 안 보인다. 이 튜토리얼은 방향 계산에만 쓰고 위치에 넣지 않아서 문제가 없지만, 위치에 넣을 때는 mousePos.z = 0f;를 한 줄 넣는다.
position.value는 Vector2인데 Vector3에 넣고 있다. Vector2에서 Vector3로는 z=0으로 자동 변환된다.
방향 벡터와 normalized
Vector2 direction = mousePos - transform.position; // (가)
Vector2 direction = (mousePos - transform.position).normalized; // (나)mousePos - transform.position은 “나에게서 마우스까지"의 벡터다. .normalized는 방향은 그대로 두고 길이를 1로 만든다. C++로 치면 v / v.Length()다.
| (가) | (나) | |
|---|---|---|
| 방향 | 마우스 쪽 | 같음 |
| 길이 | 마우스까지의 거리 | 항상 1 |
이 벡터를 힘으로 쓸 때 차이가 난다. (가)면 마우스가 멀수록 세게 밀리고 가까우면 약해진다. 자석에 끌리는 움직임이다. (나)면 거리와 무관하게 항상 같은 힘이다. 튜토리얼이 (나)로 바꾸는 이유는 “일정한 속도로 조종"을 원하기 때문이다. (가)도 쓸모가 있다. 목표 지점에 가까워지면서 부드럽게 멈추게 할 때 쓴다.
길이 0인 벡터를 normalized하면 유니티는 예외 대신 (0, 0)을 돌려준다.
회전과 추진
transform.up = direction; // 오브젝트의 "위"가 direction을 향하도록 회전
rb.AddForce(direction * thrustForce); // 그 방향으로 힘transform.up에 벡터를 대입하면 각도 계산 없이 회전이 정해진다. 삼각형 스프라이트의 뾰족한 쪽이 up이다.
AddForce는 힘을 누적한다. 매 프레임 누르고 있으면 계속 가속한다. 튜토리얼은 thrustForce를 public으로 열어 Inspector에서 조정하게 한다. 선택 과제에 최대 속도 제한이 있다.
if (rb.linearVelocity.magnitude > maxSpeed)
{
rb.linearVelocity = rb.linearVelocity.normalized * maxSpeed;
}rb.velocity가 rb.linearVelocity로 이름이 바뀐 것이 2022년과 다른 점이다.
충돌하면 파괴
void OnCollisionEnter2D(Collision2D collision)
{
Destroy(gameObject);
}Player의 콜라이더가 다른 콜라이더에 닿는 순간 유니티가 이 함수를 부른다. 이름과 인자 타입이 정확해야 한다. OnColl까지 치고 자동 완성에서 고르는 것이 안전하다. Destroy(gameObject)는 이 스크립트가 붙은 GameObject를 없앤다. 즉시가 아니라 그 프레임 끝에 실행된다.
4편 최종 코드
using UnityEngine;
using UnityEngine.InputSystem;
public class PlayerController : MonoBehaviour
{
public float thrustForce = 1f;
Rigidbody2D rb;
void Start()
{
rb = GetComponent<Rigidbody2D>();
}
void Update()
{
if (Mouse.current.leftButton.isPressed)
{
Vector3 mousePos = Camera.main.ScreenToWorldPoint(Mouse.current.position.value);
Vector2 direction = (mousePos - transform.position).normalized;
transform.up = direction;
rb.AddForce(direction * thrustForce);
}
}
void OnCollisionEnter2D(Collision2D collision)
{
Destroy(gameObject);
}
}Camera.main은 태그가 MainCamera인 카메라를 찾는 함수다. Unity 2020 이후 캐싱되어 Update에서 써도 괜찮지만, Start에서 필드에 넣어 두는 것이 관례다.
5편: 점수 시스템
생존 시간 재기
private float elapsedTime = 0f;
void Update()
{
elapsedTime += Time.deltaTime;
}Time.deltaTime은 이전 프레임부터 지난 시간(초)이다. 서버의 dt다. 매 프레임 더하면 프레임 속도와 무관하게 실제 시간이 쌓인다. 60fps든 144fps든 1초 뒤에는 1.0이다.
점수 배율과 정수화
private float score = 0f;
public float scoreMultiplier = 10f;
void Update()
{
elapsedTime += Time.deltaTime;
score = Mathf.FloorToInt(elapsedTime * scoreMultiplier);
}Mathf.FloorToInt(3.7f)는 3이다. 내림이라 정해진 시간이 완전히 지나야 점수가 오른다.
여기서 질문. 매 프레임 내림 연산을 하는데 부하가 꽤 있지 않은가.
FloorToInt 자체는 부하가 없다. 곱셈 한 번과 내림 한 번이고, 서버에서 매 틱 하는 좌표 계산보다 가볍다. C++ 감각으로 연산량을 보는 것은 맞는 방향인데, C#에서는 기준이 하나 더 있다. 매 프레임 부하로 실제 문제가 되는 것은 연산이 아니라 힙 할당이다.
Update에서 매 프레임 |
실제 부담 |
|---|---|
곱셈, 나눗셈, FloorToInt, Sqrt, Vector 연산 |
거의 없음 |
GetComponent, Find |
있음. 검색이라서 |
문자열 만들기, new 클래스, 캡처 람다, LINQ |
있음. GC가 쌓여서 |
Debug.Log |
큼. 문자열 + 로그 시스템 |
그리고 score가 float인데 정수를 넣고 있다. FloorToInt가 int를 돌려주는데 float에 넣으면 다시 변환된다. 정수인 값은 int로 둔다.
GC 이야기
위 표에서 “GC가 쌓여서"가 무슨 뜻인지 짚고 간다. 지난 글에서 new를 하고 delete를 안 한다고 했다. 그러면 지우는 시점을 알 수 없으니 그 부하를 계산해야 하고, 풀을 만들어 new 자체를 안 하게 해야 하는 것 아닌가. 정확히 그 방향이 유니티의 표준 해법이다. 몇 가지만 정정하면 된다.
GC는 객체를 하나씩 무작위로 지우는 것이 아니라, 힙에 쓰레기가 임계치까지 쌓이면 한 번에 돈다. 그래서 부하가 매 프레임 조금씩이 아니라 어느 한 프레임에 몰려서 온다. 60fps로 잘 돌다가 GC가 도는 프레임만 수십 ms가 걸려 화면이 툭 끊긴다. 서버라면 틱 하나 늦어도 티가 안 나지만 클라이언트는 그 프레임이 눈에 보인다.
그러면 new를 사실상 못 쓰는 것인가. 아니다. 문제는 new의 존재가 아니라 초당 몇 번 만들고 버리느냐다.
| 상황 | GC |
|---|---|
Start에서 리스트 10개를 new |
한 번 만들고 계속 쓴다. 쓰레기가 안 생긴다 |
| 버튼 클릭마다 문자열 하나 | 초당 몇 번. 임계치까지 몇 분. 사실상 무관 |
Update에서 매 프레임 문자열 하나 |
초당 60개. 몇 초마다 GC. 이것이 문제 |
Update에서 매 프레임 Vector3 |
struct라 힙에 안 간다. 무관 |
new가 쓰레기가 되는 것은 버렸을 때다. 만들어서 계속 들고 있는 객체는 GC 대상이 아니다. 그래서 규칙은 “new를 하지 마라"가 아니라 이것이다.
- 매 프레임 도는 곳(
Update,FixedUpdate, 매 프레임 도는 코루틴)에서만new를 의심한다.Start, 버튼 클릭, 손님 도착, 구매, 세이브 같은 사건 처리 코드에서는 자유롭게 쓴다. - 초당 여러 번 생성/파괴되는 것(총알, 손님, 이펙트)은 풀링한다. 서버의 객체 풀과 같다.
- 문자열, 람다 캡처, LINQ는 풀링이 안 되는 종류라 매 프레임 코드에서 빼는 것이 해법이다.
C++에서는 new/delete 자체의 할당자 비용을 신경 썼다. C#에서 new는 오히려 C++보다 싸다. 힙 포인터를 밀기만 한다. 비용은 나중에 GC가 청소할 때 한꺼번에 온다. 풀링은 할당 비용이 아니라 GC 스파이크를 없애기 위한 것이다. Unity 6은 기본이 Incremental GC라 청소를 여러 프레임에 나눠 하기 때문에 예전보다 스파이크가 덜하지만, 매 프레임 할당은 여전히 피한다.
UI Toolkit으로 점수 띄우기
Hierarchy에서 UI Toolkit > UI Document로 “GameUI"를 만든다. Assets에 UI 폴더를 만들고 Create > UI Toolkit > UI Document로 “UILayout” 에셋을 만든 뒤, GameUI의 Source Asset 칸에 넣는다.
UILayout을 더블클릭하면 UI Builder 창이 열린다. Library에서 Label을 Hierarchy로 끌어 넣고, 이름을 “ScoreLabel”, Text를 “Score: 0"으로 한다. Inline Styles에서 상단 오프셋 20px, Align Self를 Center, 글꼴 크기 20, 배경과 대비되는 색으로 바꾸고 Ctrl+S로 저장한다.
여기서 걸렸다. Scene 뷰에 UI가 안 보인다. 정상이다. UI Toolkit의 런타임 UI는 Scene 뷰에 그려지지 않고 Game 뷰에서만 보인다. UIDocument 하나가 .uxml을 읽어 화면 위에 별도의 층으로 그리는 방식이라 씬 안의 오브젝트가 아니기 때문이다. 배치 확인은 UI Builder의 Fit Viewport로 한다.
| UI 방식 | Scene 뷰 | 편집 |
|---|---|---|
| UI Toolkit (이 튜토리얼) | 안 보임 | UI Builder 창 |
| uGUI (Canvas) | 보임. 씬에 GameObject로 존재 | Scene 뷰에서 직접 |
유니티는 런타임 게임 UI에는 아직 uGUI를 권장한다. 학원에서 배운 것도 uGUI다. UI Toolkit은 “이런 것도 있다” 정도로 보고 넘어간다.
스크립트에서 라벨 찾기
using UnityEngine.UIElements;
public UIDocument uiDocument;
private Label scoreText;
void Start()
{
scoreText = uiDocument.rootVisualElement.Q<Label>("ScoreLabel");
}
void Update()
{
scoreText.text = "Score: " + score;
}Player를 선택하고 Inspector의 Ui Document 칸에 GameUI 오브젝트를 드래그해야 한다. 이것을 빼먹고 Play를 눌렀더니 Start의 저 줄에서 NullReferenceException이 났다.
꼭 기억할 것 ② 그 줄에서 예외가 나면
Q가 못 찾은 것이 아니라uiDocument가 null인 것이다scoreText = uiDocument.rootVisualElement.Q<Label>("ScoreLabel"); // ─────┬──── 이것이 null이면 .rootVisualElement에서 예외
Q는 못 찾아도 예외를 던지지 않고 null을 돌려준다. 그러니 이 줄에서 터졌다면Q까지 가지도 못한 것이고, 원인은 Inspector 연결 누락이다.Q가 못 찾은 경우는 다음 줄scoreText.text = ...에서 터진다. Console의 에러를 더블클릭하면 줄 번호로 이동하니 어느 줄인지 보면 구분된다. Inspector 연결 누락은 유니티에서 가장 흔한 실수라, 필수 참조는Start에서 검사하고Debug.LogError로 명확한 메시지를 남기는 것이 관례다.
.rootVisualElement는 최상위 컨테이너, .Q<Label>("ScoreLabel")은 “이름이 ScoreLabel인 Label을 찾아라"다. UI Builder에서 지정한 이름과 대소문자까지 같아야 한다.
라벨에 아무것도 안 뜬다면 순서를 확인한다. 5~7단계는 라벨을 만들고 꾸미는 것까지이고, 스크립트가 점수를 쓰는 scoreText.text = ...는 9단계에서 나온다. 그 전에는 자리 표시자 “Score: 0” 그대로여야 정상이다.
마지막으로 Panel Settings 에셋의 Scale Mode를 Scale With Screen Size로 바꾼다. 해상도가 달라도 UI 크기가 따라간다.
5편 최종 코드와 리뷰
using UnityEngine;
using UnityEngine.InputSystem;
using UnityEngine.UIElements;
public class PlayerController : MonoBehaviour
{
private float elapsedTime = 0f;
private float score = 0f;
public float scoreMultiplier = 10f;
public float thrustForce = 1f;
Rigidbody2D rb;
public UIDocument uiDocument;
private Label scoreText;
void Start()
{
rb = GetComponent<Rigidbody2D>();
scoreText = uiDocument.rootVisualElement.Q<Label>("ScoreLabel");
}
void Update()
{
elapsedTime += Time.deltaTime;
score = Mathf.FloorToInt(elapsedTime * scoreMultiplier);
scoreText.text = "Score: " + score;
if (Mouse.current.leftButton.isPressed) {
Vector3 mousePos = Camera.main.ScreenToWorldPoint(Mouse.current.position.value);
Vector2 direction = (mousePos - transform.position).normalized;
transform.up = direction;
rb.AddForce(direction * thrustForce);
}
}
void OnCollisionEnter2D(Collision2D collision)
{
Destroy(gameObject);
}
}꼭 기억할 것 ③
Update안의 문자열 연결은 매 프레임 힙 할당이다
scoreText.text = "Score: " + score;는 초당 60번 새 문자열을 만든다. 점수는 초당 10번만 바뀌는데 60번 문자열을 만드는 것이다. 바뀔 때만 갱신하면 60번이 10번이 되고, 이벤트 기반으로 바꾸면 0번이 된다. 이것이Update를 리뷰할 때 가장 먼저 보는 항목이다.
int score;
void Update()
{
elapsedTime += Time.deltaTime;
int newScore = Mathf.FloorToInt(elapsedTime * scoreMultiplier);
if (newScore != score) // 초당 10번만 들어온다
{
score = newScore;
scoreText.text = "Score: " + score;
}
}하나 더. 처음에 내가 짠 버전은 점수 계산이 if (isPressed) 안에 들어가 있었다. 그러면 마우스를 누르고 있을 때만 점수가 오른다. 튜토리얼 의도는 “살아 있는 시간 = 점수"이므로 if 밖에 두어야 한다.
뭘 배웠지?
- Rigidbody2D는 자식 콜라이더를 전부 흡수한다. 한 덩어리로 움직이는 물체는 Rigidbody 하나 + 자식 콜라이더 여러 개다. 관절이 물리로 흔들려야 할 때만 Rigidbody 여러 개 + Joint2D다.
- Input System은
Mouse.current.leftButton.isPressed로 읽는다.Mouse.current는 모바일에서 null이다. - 화면 좌표(픽셀, 왼쪽 아래 원점)와 월드 좌표(유닛, 씬 원점)는 다르고
Camera.main.ScreenToWorldPoint로 변환한다. 결과의 Z는 카메라 Z가 되니 위치에 넣을 때 0으로 맞춘다. .normalized는 방향은 두고 길이를 1로 만든다.OnCollisionEnter2D(Collision2D)는 이름과 인자가 정확해야 불린다.Destroy는 그 프레임 끝에 실행된다.Time.deltaTime을 매 프레임 더하면 프레임 속도와 무관한 실제 시간이 된다.- 매 프레임 부하는 연산이 아니라 힙 할당이다. GC는 쓰레기가 임계치에 쌓이면 한 프레임에 몰아서 돈다.
new를 못 쓰는 것이 아니다. 매 프레임 도는 곳에서만 피하고, 사건 처리 코드에서는 자유롭게 쓴다. 자주 생성/파괴되는 오브젝트는 풀링한다.- UI Toolkit 런타임 UI는 Scene 뷰에 안 보인다. 런타임 게임 UI는 uGUI가 공식 권장이다.
NullReferenceException은 어느 줄에서 났는지 보면 원인이 갈린다. Inspector 연결 누락이 가장 흔하다.
생각 해보기
문제 1
Player의 자식 Triangle에도 Rigidbody2D를 붙이면 어떤 일이 벌어질까? (난이도 : 中)
문제 2
Camera.main.ScreenToWorldPoint(Mouse.current.position.value)의 결과를 그대로 transform.position에 넣었더니 플레이어가 사라졌다. 왜 그럴까? 한 줄로 고쳐 보자. (난이도 : 中)
문제 3
rb.AddForce(direction * thrustForce)에서 direction을 normalized 하지 않으면 조작감이 어떻게 달라질까? 그 조작감이 오히려 유용한 상황은 무엇일까? (난이도 : 中)
문제 4
아래 세 줄 중 매 프레임 힙 할당이 일어나는 것은 무엇일까? (난이도 : 中)
void Update()
{
Vector3 v = new Vector3(1, 2, 0); // (가)
string s = "Score: " + score; // (나)
float f = Mathf.FloorToInt(elapsedTime * 10); // (다)
}문제 5
“GC는 쓰레기가 임계치에 쌓이면 돈다"면, 버튼을 누를 때마다 문자열을 하나 만드는 코드는 문제가 될까? Update에서 매 프레임 만드는 것과 무엇이 다를까? (난이도 : 中)
문제 6
아래 코드에서 NullReferenceException이 첫 줄에서 났을 때와 둘째 줄에서 났을 때, 원인은 각각 무엇일까? (난이도 : 上)
scoreText = uiDocument.rootVisualElement.Q<Label>("ScoreLabel");
scoreText.text = "Score: 0";문제 7
5편 최종 코드의 Update에서 매 프레임 문자열을 만들지 않도록 고쳐 보자. 그리고 이벤트 기반으로 바꾸면 어떤 구조가 될지 설명해 보자. (난이도 : 上)