2D Godot 2D 게임 기초
P1 렌더와 프레임

6강그리기 순서와 렌더링

z_index, Y-sort, CanvasLayer, 드로우 콜.

이 강의 목차 (7)
  1. 2D에는 깊이가 없다. 순서만 있다
  2. 규칙 1 — 씬 트리의 순서
  3. 규칙 2 — z_index
  4. 규칙 3 — Y-sort (탑다운 게임의 핵심)
  5. 규칙 4 — CanvasLayer (UI는 세계 밖에 있다)
  6. 드로우 콜 — 왜 많으면 느려지는가
  7. 실습 — 순서를 직접 뒤집어 보기
이번 강의의 목표
  • 2D에서 무엇이 위에 그려질지를 결정하는 규칙을 안다.
  • z_index, Y-sort, CanvasLayer를 구분해서 쓴다.
  • 드로우 콜이 무엇이고 왜 많으면 느려지는지 감각적으로 안다.

2D에는 깊이가 없다. 순서만 있다#

3D는 카메라로부터의 거리를 계산해서 가까운 것이 앞에 보입니다. 2D는 그런 게 없습니다. 그냥 나중에 그린 것이 위에 덮입니다.

화가의 방식

화가가 캔버스에 배경을 칠하고, 그 위에 산을 칠하고, 그 위에 사람을 칠합니다. 나중에 칠한 물감이 앞의 물감을 덮습니다. 2D 렌더링은 이걸 그대로 합니다. 실제로 painter's algorithm(화가 알고리즘) 이라고 부릅니다.

그래서 2D에서 "앞뒤"는 전부 그리는 순서를 어떻게 정하느냐의 문제입니다.

규칙 1 — 씬 트리의 순서#

기본 규칙은 아주 단순합니다.

씬 독에서 아래에 있을수록 나중에 그려진다 = 위에 보인다
text
Main
├─ Background    ← 가장 먼저 그림 (제일 뒤)
├─ Ground
├─ Enemy
└─ Player        ← 가장 나중에 그림 (제일 앞)

자식은 부모보다 나중에 그려집니다. 즉 자식이 부모 위에 올라갑니다.

씬 독에서 노드를 위아래로 끌어 순서를 바꾸면 그리기 순서가 바뀝니다. 대부분의 정렬 문제는 여기서 해결됩니다.

규칙 2 — z_index#

트리 순서를 바꾸기 곤란할 때가 있습니다. 그럴 때 z_index로 강제로 끌어올립니다.

gdscript
z_index = 10  # 기본은 0. 클수록 앞에 온다. 음수도 가능
  • 범위는 -4096 ~ 4096
  • 같은 부모 안에서만 비교됩니다(기본값 기준)
  • z_as_relative 가 켜져 있으면 부모의 z에 더해집니다(기본 켜짐)
z_index를 남발하면 반드시 후회한다

프로젝트 후반에 "얘가 자꾸 뒤로 가요" 할 때마다 z_index = 100, z_index = 999 를 붙이다 보면 아무도 순서를 예측할 수 없는 상태가 됩니다.

권장 — 프로젝트 시작할 때 레이어 상수를 정해두고 그것만 씁니다.

gdscript
# res://z_layers.gd  (오토로드로 등록)
const BG      := -100
const GROUND  := 0
const ACTOR   := 10
const EFFECT  := 50
const UI_WORLD := 90

규칙 3 — Y-sort (탑다운 게임의 핵심)#

탑다운(위에서 내려다보는) 게임에서는 아래쪽에 있는 것이 앞에 보여야 자연스럽습니다. 캐릭터가 나무보다 아래에 서 있으면 나무 앞에, 위에 서 있으면 나무 뒤에 가려져야 합니다.

이걸 매 프레임 z_index로 계산하려면 골치 아픕니다. Godot에는 기능이 있습니다.

Y-sort

부모 노드의 Y Sort Enabled 를 켜면, 그 자식들을 global_position.y 순서대로 자동 정렬해서 그립니다. Y가 큰(= 화면에서 아래쪽) 것이 나중에 그려져 앞에 옵니다.

설정 방법

  1. 캐릭터·나무·상자를 모두 담고 있는 부모 노드(예: World)를 선택
  2. 인스펙터에서 Ordering → Y Sort Enabled 체크
Y-sort는 "피벗 위치"로 정렬한다

정렬 기준은 이미지의 아래 끝이 아니라 노드의 원점(피벗) 입니다. 5강에서 "캐릭터 원점은 발밑으로"라고 한 이유가 여기 있습니다.

원점이 이미지 중앙에 있으면, 캐릭터가 나무 밑동보다 아래 서 있어도 중앙 좌표는 아직 위쪽이라 나무 뒤로 숨어버립니다.

Y-sort가 안 먹는 대표적인 경우
  1. 자식이 또 다른 Node2D 안에 묶여 있을 때 — Y-sort는 직계 자식들만 정렬합니다. 중간 컨테이너 노드에도 Y Sort를 켜야 관통합니다.
  2. z_index를 같이 쓴 경우z_index가 먼저 적용되고, 같은 z 안에서 Y-sort가 돕니다. 즉 z_index가 다르면 Y-sort는 무의미합니다.
  3. 긴 물체 — 담장이나 다리처럼 세로로 긴 물체는 한 점 기준 정렬로는 해결이 안 됩니다. 보통 잘라서 여러 조각으로 나눕니다.

규칙 4 — CanvasLayer (UI는 세계 밖에 있다)#

카메라가 움직이면 세계의 모든 것이 같이 밀려납니다. 그런데 체력바나 점수는 화면에 고정되어야 합니다.

CanvasLayer카메라의 영향을 받지 않는 별도의 레이어를 만듭니다.

text
Main
├─ World      (Node2D)      ← 카메라가 비추는 세계
│   ├─ Player
│   └─ Enemies
└─ HUD        (CanvasLayer) ← 화면에 고정
    ├─ HealthBar
    └─ ScoreLabel

CanvasLayerlayer 번호가 있어서 여러 개를 쌓을 수 있습니다.

layer 용도
-1 시차 배경(패럴랙스)
0 기본 (게임 세계)
1 HUD
10 일시정지 메뉴
100 화면 전환 페이드, 로딩
직접 확인하기

LabelCanvasLayer 안이 아니라 World 안에 두고 카메라를 움직여 보세요. 글자가 화면 밖으로 밀려나갑니다. 3강에서 FPS 라벨을 CanvasLayer에 넣은 이유입니다.

드로우 콜 — 왜 많으면 느려지는가#

여기서부터는 "왜 게임이 느려지는가"의 뿌리입니다. 비개발자도 감각은 잡아두면 좋습니다.

드로우 콜

CPU가 그래픽 카드에게 "이거 그려라" 하고 명령을 한 번 보내는 것이 드로우 콜 1회입니다.

문제는 명령을 보내는 행위 자체에 고정 비용이 있다는 점입니다. 그림이 아무리 작아도, 명령을 보내는 데 드는 비용은 똑같이 듭니다.

택배 비유
  • 물건 100개를 한 상자에 담아 한 번 보내기 → 배송비 1회
  • 물건 100개를 각각 따로 100번 보내기 → 배송비 100회

물건의 총량은 같은데 비용은 100배입니다. 그래픽 카드는 한 번에 많이 받는 걸 좋아합니다.

같은 텍스처를 쓰는 스프라이트들은 하나로 묶여(batching) 한 번에 보내집니다. 그래서 실무에서 이런 최적화가 나옵니다.

  • 텍스처 아틀라스 — 여러 그림을 큰 이미지 한 장에 모아 붙인다. → 같은 텍스처이므로 한 번에 그려진다.
  • 머티리얼 통일 — 셰이더나 블렌드 모드가 다르면 배칭이 끊긴다.
  • z_index 남발 자제 — 순서가 뒤섞이면 배칭이 끊긴다.
배칭을 끊는 대표적인 행동

z_indexCanvasLayer로 A-B-A-B 순서를 만들면 같은 텍스처라도 중간에 다른 게 끼어들어 묶을 수 없게 됩니다. "정렬을 손보다가 갑자기 느려졌다"의 흔한 원인입니다.

2D에서 진짜 무거운 것은 따로 있다

드로우 콜보다 더 자주 문제가 되는 건 오버드로(overdraw) 입니다.

같은 픽셀을 여러 번 덧칠하는 것을 말합니다. 반투명 이펙트를 화면 가득 10장 겹치면, 화면의 모든 픽셀을 10번씩 칠하는 셈입니다. 해상도가 클수록 정직하게 비례해 느려집니다.

증상 구분법

  • 오브젝트 수를 줄였더니 빨라졌다 → 드로우 콜 문제
  • 창 크기를 줄였더니 빨라졌다 → 오버드로(픽셀) 문제

이 구분 하나로 최적화 방향이 갈립니다. 11강 이후 실제 게임을 만들면서 다시 만납니다.

실습 — 순서를 직접 뒤집어 보기#

1) 겹쳐 놓기#

Sprite2D 세 개를 조금씩 겹치게 배치하고 이름을 A, B, C로 둡니다. 인스펙터의 Modulate 색을 각각 빨강·초록·파랑으로 바꾸면 구분하기 좋습니다.

씬 독에서 CA 위로 끌어 올려보세요. 화면에서 앞뒤가 바뀝니다.

2) 코드로 순서 바꾸기#

gdscript
extends Node2D

@onready var sprites: Array[Node] = [$A, $B, $C]
var _top := 0

func _unhandled_input(event: InputEvent) -> void:
	if event.is_action_pressed("ui_accept"):  # 스페이스바
		# 누를 때마다 다음 스프라이트를 맨 앞으로.
		for i in sprites.size():
			sprites[i].z_index = 0
		sprites[_top].z_index = 10
		_top = (_top + 1) % sprites.size()

3) Y-sort 체험#

  1. Main(또는 스프라이트들의 부모)에서 Y Sort Enabled 를 켭니다.
  2. 스프라이트들을 위아래로 흩어 놓습니다.
  3. 하나를 드래그해 위아래로 움직이면 자동으로 앞뒤가 바뀝니다.
gdscript
# 캐릭터를 마우스로 끌어 Y-sort를 확인하는 코드
extends Sprite2D

var _drag := false

func _unhandled_input(event: InputEvent) -> void:
	if event is InputEventMouseButton:
		if event.button_index == MOUSE_BUTTON_LEFT:
			_drag = event.pressed
	elif event is InputEventMouseMotion and _drag:
		global_position = get_global_mouse_position()
직접 바꿔보기
  1. Y-sort를 켠 상태에서 z_index를 하나만 5로 올려보세요. Y-sort가 무시됩니까?
  2. Sprite2DOffset을 조정해 원점을 발밑으로 옮기고 다시 정렬해 보세요. 정렬 결과가 더 자연스러워집니까?
  3. CanvasLayer를 하나 추가하고 스프라이트 하나를 그 안으로 옮겨보세요. Y-sort가 그 스프라이트에는 적용됩니까? (다른 캔버스이므로 적용되지 않습니다)
  • 개발자 — 정렬 문제는 ① 트리 순서 → ② Y-sort → ③ z_index 순으로 시도하세요. z_index부터 손대면 나중에 정리가 불가능해집니다.
  • 기획자 — "캐릭터가 나무에 가려요"는 대개 피벗 규칙 문제입니다. 버그 리포트에 "캐릭터 발이 나무 밑동보다 아래인데 뒤에 그려짐"처럼 위치 관계를 써주면 원인 파악이 몇 배 빨라집니다.
  • 디자이너 — 스프라이트를 한 장의 아틀라스로 모아주면 드로우 콜이 줄어 실제로 빨라집니다. 또 반투명이 겹치는 이펙트는 비쌉니다. 화면을 덮는 이펙트는 개수를 미리 상의하세요.
자주 나는 오류

"Y Sort를 켰는데 아무 일도 안 일어난다" → 켜야 하는 건 정렬 대상이 아니라 그 부모 노드입니다. 캐릭터가 아니라 캐릭터들을 담은 노드에 켭니다.

"UI가 캐릭터 뒤로 사라진다" → UI를 CanvasLayer 안으로 옮기세요. z_index로 해결하려 하면 계속 재발합니다.

"배경이 캐릭터를 덮는다" → 씬 독에서 배경이 캐릭터보다 아래에 있습니다. 위로 올리세요.

여기까지 확인
  • 2D는 나중에 그린 것이 위에 온다
  • 트리 순서 → Y-sort → z_index 순으로 해결한다
  • Y-sort는 피벗 위치를 기준으로 정렬한다
  • UI는 CanvasLayer에 둔다
  • 드로우 콜과 오버드로의 차이를 말할 수 있다

P1 정리 — 여기까지가 강좌의 뼈대입니다. 프레임, delta, 좌표, 그리기 순서. 앞으로 나오는 모든 것이 이 넷 위에 얹힙니다.

다음 강의 — P2로 들어갑니다. 이제 플레이어가 조작합니다. 입력부터 봅니다.