5강좌표계와 변환
원점, Y축, 피벗, 로컬과 글로벌.
이 강의 목차 (6)
- 2D 좌표계의 원점과 Y축 방향을 안다.
position/rotation/scale이 무엇을 바꾸는지 구분한다.- 로컬 좌표와 글로벌 좌표의 차이를 설명할 수 있다.
- 피벗(원점)이 왜 디자인 단계에서 중요한지 안다.
화면의 좌표는 수학 시간과 다르다#
수학 시간에 배운 좌표평면은 원점이 가운데 있고 Y가 위로 갑니다. 컴퓨터 화면은 다릅니다.
(0,0) ────────────────▶ X 증가
│
│ 화면
│
▼
Y 증가- 원점 (0, 0)은 왼쪽 위 모서리
- X는 오른쪽으로 갈수록 증가 (같음)
- Y는 아래로 갈수록 증가 (반대!)
이것 하나 때문에 초보가 계속 헷갈립니다.
- 위로 점프 =
velocity.y = -400(음수!) - 아래로 낙하 =
velocity.y = +400 - 중력은
velocity.y += 980 * delta(양수를 더하면 아래로)
"점프인데 왜 마이너스지?"가 나오면 이 그림을 다시 떠올리세요.
왜 화면 좌표는 Y가 아래로 갈까
브라운관 시절 화면은 왼쪽 위부터 오른쪽 아래로 한 줄씩 전자빔을 훑어 그렸습니다. 그 스캔 순서가 그대로 메모리 배열 순서가 되었고, 지금까지 표준으로 남았습니다. 글을 쓰는 방향(좌→우, 위→아래)과도 같아서 자연스럽기도 합니다.
참고로 3D 그래픽스와 물리 엔진 일부는 Y가 위입니다. Godot의 2D는 화면 관습을 따릅니다.
위치·회전·크기 — 변환의 3요소#
Node2D를 상속한 모든 노드는 이 세 가지를 가집니다. 인스펙터의 Transform 항목입니다.
| 속성 | 타입 | 의미 | 단위 |
|---|---|---|---|
position |
Vector2 | 어디에 있는가 | 픽셀 |
rotation |
float | 얼마나 돌아있는가 | 라디안 |
rotation_degrees |
float | 같은 값을 도(°)로 | 도 |
scale |
Vector2 | 얼마나 크게 | 배율 (1.0 = 원래 크기) |
skew |
float | 기울임 | 라디안 |
인스펙터에는 도(degree) 로 보이지만 코드의 rotation 은 라디안입니다.
rotation = deg_to_rad(90) # 이렇게 쓰거나
rotation_degrees = 90 # 이렇게 쓴다
rotation = PI / 2 # 같은 값(90도)rotation = 90 이라고 쓰면 90라디안, 즉 약 5156도 회전합니다.
Vector2 — 2D의 기본 자료형#
position은 그냥 숫자가 아니라 Vector2(x와 y를 묶은 값) 입니다.
게임 코드의 절반은 이 타입을 다룹니다.
var v := Vector2(100, 50)
v.x # 100
v.y # 50
v + Vector2(10, 10) # (110, 60)
v * 2 # (200, 100)
v.length() # 원점에서의 거리 (약 111.8)
v.normalized() # 길이가 1인 같은 방향 벡터
v.angle() # 방향(라디안)
v.distance_to(other) # 다른 점까지의 거리같은 Vector2(100, 50) 이 문맥에 따라 두 가지로 읽힙니다.
position에 쓰면 → 점: "화면의 (100, 50) 자리"velocity에 쓰면 → 화살표: "오른쪽으로 100, 아래로 50만큼의 속도"
방향만 필요할 땐 normalized() 로 길이를 1로 맞춥니다.
그래야 대각선으로 갈 때 더 빨라지는 문제가 안 생깁니다(7강에서 다시 나옵니다).
편리한 상수들:
Vector2.ZERO # (0, 0)
Vector2.ONE # (1, 1)
Vector2.LEFT # (-1, 0)
Vector2.RIGHT # (1, 0)
Vector2.UP # (0, -1) ← Y가 위로 갈 때 음수!
Vector2.DOWN # (0, 1)로컬과 글로벌#
2강에서 "부모가 움직이면 자식도 따라온다"를 봤습니다. 그 원리가 여기 있습니다.
position(로컬) — 부모를 기준으로 한 상대 위치global_position(글로벌) — 화면 세계 전체를 기준으로 한 절대 위치
부모가 원점에 있으면 둘이 같습니다. 부모가 움직이면 달라집니다.
예를 들어 이런 구조에서,
Player (global_position = (500, 300))
└─ Gun (position = (20, -5))Gun.position 은 (20, -5) 이지만 Gun.global_position 은 (520, 295) 입니다.
- 로컬 = "컵은 접시 중심에서 오른쪽으로 3cm"
- 글로벌 = "컵은 방 왼쪽 벽에서 2m, 앞 벽에서 1.5m"
접시를 옮기면 로컬은 그대로, 글로벌만 바뀝니다. 접시를 회전시키면 글로벌 위치가 원을 그리며 돕니다.
언제 무엇을 쓰나#
| 하려는 일 | 쓰는 것 |
|---|---|
| 캐릭터를 앞으로 움직인다 | position |
| 총알을 총구 위치에서 생성한다 | global_position |
| 적이 플레이어 쪽을 본다 | global_position 끼리 계산 |
| 팔을 몸통 기준으로 흔든다 | position |
| 마우스 위치를 안다 | get_global_mouse_position() |
총알을 만들어 Player의 자식으로 붙이면, 플레이어가 움직일 때 총알이 따라갑니다.
총알은 발사되는 순간 부모에게서 독립해야 합니다.
var b := BULLET.instantiate()
# 총알은 씬 루트(월드)에 붙인다. 부모를 따라가면 안 되므로.
get_tree().current_scene.add_child(b)
# 위치는 총구의 "세계 좌표"로 지정한다.
b.global_position = muzzle.global_position
b.rotation = muzzle.global_rotation피벗 — 무엇을 중심으로 도는가#
Sprite2D는 기본적으로 이미지의 정중앙을 원점으로 삼습니다.
그래서 rotation을 주면 이미지가 가운데를 축으로 돕니다.
문제는 이게 항상 원하는 축이 아니라는 겁니다.
| 원하는 동작 | 필요한 피벗 |
|---|---|
| 캐릭터가 제자리에서 돈다 | 몸 중앙 |
| 캐릭터가 바닥에 서 있다 | 발밑 |
| 총이 손잡이를 축으로 겨눈다 | 손잡이 |
| 문이 경첩을 축으로 열린다 | 경첩 |
| 대포가 포신 뒤를 축으로 든다 | 포신 뒤쪽 |
Godot에서 피벗을 바꾸는 방법은 두 가지입니다.
방법 1 — Sprite2D의 Offset
인스펙터의 Offset → Offset 값을 조정합니다.
발밑을 원점으로 두려면 이미지 높이의 절반만큼 위로 올립니다((0, -32) 같은 값).
Centered 체크를 끄면 이미지 좌상단이 원점이 됩니다.
방법 2 — 부모 노드를 하나 더 두기 (권장)
Player (Node2D) ← 발밑 기준. 이게 캐릭터의 "위치"
└─ Sprite (Sprite2D) ← position (0, -32) 로 올려둔다이 구조가 훨씬 유연합니다. Player를 회전시키면 발밑을 축으로 돌고,
Sprite만 따로 흔들 수도 있습니다.
- 개발자 — 캐릭터의 "위치"는 발밑으로 잡는 게 대부분 편합니다. 바닥 판정, 그림자 위치, Y-sort(6강) 정렬이 전부 발밑 기준이기 때문입니다.
- 기획자 — "적이 바닥에 반쯤 묻혀 보여요"는 대개 버그가 아니라 피벗 규칙이 없는 것입니다. 프로젝트 시작 시 "모든 캐릭터의 원점은 발밑"처럼 규칙을 문서에 못 박으세요.
- 디자이너 — 스프라이트를 그릴 때 캔버스 안에서 캐릭터의 위치를 통일하세요. 같은 캐릭터의 대기/걷기/공격 이미지에서 발 위치가 3픽셀씩 다르면 캐릭터가 떨립니다. 이건 코드로 못 고칩니다.
실습 — 궤도 도는 위성#
부모-자식 변환을 눈으로 확인하는 가장 좋은 예제입니다.
씬 구성#
Main (Node2D)
└─ Planet (Sprite2D) 위치 (400, 300)
└─ Pivot (Node2D) 위치 (0, 0)
└─ Moon (Sprite2D) 위치 (150, 0), scale (0.4, 0.4)Pivot은 그림이 없는 빈 노드입니다. 회전축 역할만 합니다.
스크립트#
Main에 붙입니다.
extends Node2D
@onready var planet: Sprite2D = $Planet
@onready var pivot: Node2D = $Planet/Pivot
@onready var moon: Sprite2D = $Planet/Pivot/Moon
func _process(delta: float) -> void:
# 축을 돌리면 자식(달)이 원을 그린다.
pivot.rotation += 1.2 * delta
# 행성 자체도 좌우로 왕복시킨다.
var t := Time.get_ticks_msec() / 1000.0
planet.position.x = 400.0 + sin(t) * 120.0
# 달의 두 좌표를 비교해 본다.
if Engine.get_process_frames() % 60 == 0:
print("로컬 ", moon.position,
" 글로벌 ", moon.global_position)실행하면 달이 행성 주위를 돌고, 행성 자체는 좌우로 흔들립니다.
출력 패널을 보면 moon.position은 항상 (150, 0)으로 고정인데
global_position은 계속 바뀝니다. 이게 로컬과 글로벌의 차이입니다.
Moon의position.x를 150 → 300 으로: 궤도가 어떻게 되나요?Planet의scale을(2, 2)로: 달의 궤도 반지름도 같이 커집니까? (부모의 scale이 자식에게 어떻게 전파되는지 확인)Moon아래에 또Node2D+Sprite2D를 넣어 달의 위성을 만들어보세요.moon.rotation을 출력해 보세요. 부모가 돌면 자식의rotation도 바뀝니까?
2번 실험이 알려주는 것 — scale은 상속된다
부모의 scale은 자식의 위치·크기에 모두 곱해집니다.
그래서 부모를 (2,2)로 키우면 달은 2배 커지고 궤도 반지름도 2배가 됩니다.
여기서 실무 함정이 하나 나옵니다.
scale이 음수이거나 서로 다른 값(예: (2, 1))이면 자식의 회전이 찌그러집니다.
캐릭터를 좌우 반전할 때 scale.x = -1 을 쓰는 경우가 흔한데,
자식에 물리 콜라이더가 달려 있으면 물리 엔진이 경고를 내거나 이상하게 동작합니다.
권장 — 좌우 반전은 scale.x = -1 대신 Sprite2D의 flip_h = true 를 쓰세요.
그림만 뒤집고 변환은 건드리지 않습니다.
"회전시켰더니 캐릭터가 이상한 데로 날아간다"
→ 피벗이 이미지 밖에 있습니다. Offset 값을 확인하세요.
"부모를 옮겼는데 자식이 안 따라온다"
→ 자식을 코드로 global_position 에 매 프레임 대입하고 있진 않은지 보세요.
글로벌 대입은 부모의 변환을 덮어씁니다.
"Node2D를 추가했는데 화면에 안 보인다"
→ 정상입니다. Node2D는 위치만 가지고 아무것도 그리지 않습니다.
에디터에서 작은 십자 표시로만 보입니다.
- 원점은 왼쪽 위, Y는 아래로 증가한다
-
rotation은 라디안,rotation_degrees는 도다 -
position은 부모 기준,global_position은 세계 기준이다 - 피벗을 바꾸는 두 가지 방법을 안다
- 궤도 예제에서 로컬은 고정, 글로벌은 변하는 걸 확인했다
다음 강의 — 이제 물체를 원하는 자리에 놓을 수 있습니다. 그런데 겹치면 누가 위에 오나요? 그리기 순서와 렌더링을 봅니다.