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

4강delta — 프레임에 흔들리지 않는 시간

왜 속도는 항상 "초당"으로 적어야 하는가.

이 강의의 Godot 예제 프로젝트
4강 예제 내려받기 godot-2d-lecture-04-delta-time.zip · 3.2 KB

압축을 풀고 Godot 4.3 이상에서 가져오기(Import) → project.godot 선택 → F5. 씬 1개 · 스크립트 1개 · 안내는 폴더 안 README.md 에 있습니다.

이 강의 목차 (5)
  1. 문제 상황부터 본다
  2. delta — "지난 프레임 이후 흐른 시간"
  3. 실습 — 나란히 놓고 비교하기
  4. delta를 곱하면 안 되는 것들
  5. delta가 커지는 순간을 조심하기
이번 강의의 목표
  • delta가 무엇을 담고 있는 숫자인지 정확히 안다.
  • 왜 속도 * delta 로 써야 하는지 실험으로 확인한다.
  • 델타를 곱하면 안 되는 경우도 구분한다.

문제 상황부터 본다#

캐릭터를 오른쪽으로 움직이는 가장 순진한 코드입니다.

gdscript
func _process(_delta: float) -> void:
	position.x += 5

"한 프레임에 5픽셀씩 간다"는 뜻입니다. 잘 돌아가는 것처럼 보입니다. 그런데 이 코드에는 심각한 문제가 있습니다.

상황 1초에 이동하는 거리
60 FPS 나오는 PC 5 × 60 = 300픽셀
30 FPS로 떨어진 노트북 5 × 30 = 150픽셀
144Hz 게이밍 모니터 5 × 144 = 720픽셀

같은 게임인데 기기마다 캐릭터 속도가 다릅니다. 좋은 컴퓨터에서는 캐릭터가 2배 빠르고, 프레임이 떨어지면 슬로우 모션이 걸립니다.

이건 옛날 게임에서 실제로 일어났던 일이다

1990년대 PC 게임들이 이 방식으로 만들어졌습니다. 그래서 요즘 컴퓨터에서 옛날 게임을 돌리면 캐릭터가 순식간에 사라지거나 게임이 즉시 끝나버리는 일이 생깁니다. 당시 PC 케이스에 있던 터보(Turbo) 버튼은 이걸 맞추려고 CPU를 일부러 느리게 만드는 버튼이었습니다.

delta — "지난 프레임 이후 흐른 시간"#

Godot이 _process(delta) 로 넘겨주는 delta는 아주 단순한 값입니다.

delta의 정의

직전 프레임이 끝난 뒤 지금까지 실제로 흐른 시간(초)

  • 60 FPS면 delta ≈ 0.0166
  • 30 FPS면 delta ≈ 0.0333
  • 프레임이 잠깐 튀면 delta ≈ 0.1 처럼 커진다

여기에 초당 속도를 곱하면 "이번 프레임 동안 가야 할 거리" 가 나옵니다.

gdscript
const SPEED := 300.0   # 초당 300픽셀

func _process(delta: float) -> void:
	position.x += SPEED * delta
상황 delta 이번 프레임 이동량 1초 합계
60 FPS 0.0166 5픽셀 300픽셀
30 FPS 0.0333 10픽셀 300픽셀
144 FPS 0.0069 2.08픽셀 300픽셀
position.x += 5프레임당 5픽셀position.x += 300 * delta초당 300픽셀60 FPS1초에 300픽셀1초에 300픽셀30 FPS1초에 150픽셀1초에 300픽셀칸이 커지든 작아지든, 초록은 1초에 가는 총량이 같다
delta 를 곱하면 프레임이 몇 번 돌든 1초에 가는 총량이 같아진다.

프레임이 어떻게 흔들려도 1초에 가는 거리는 같습니다. 프레임이 떨어지면 뚝뚝 끊겨 보이지만 느려지지는 않습니다.

자동차 계기판

position.x += 5 는 "한 번 밟을 때마다 5m 간다"입니다. 몇 번 밟느냐에 따라 결과가 달라집니다. SPEED * delta 는 "시속 60km로 간다"입니다. 도로 사정이 어떻든 1시간이면 60km입니다.

게임의 모든 속도는 '초당'으로 말해야 합니다. '프레임당'은 말이 되지 않습니다.

실습 — 나란히 놓고 비교하기#

말로 백 번 듣는 것보다 한 번 보는 게 낫습니다.

1) 씬 구성#

main.tscn에 Sprite2D를 두 개 놓고 이름을 Bad, Good으로 바꿉니다. Bad는 y=150, Good은 y=250 정도로 위아래로 떨어뜨려 놓으세요.

2) 스크립트#

main.tscn 루트에 붙입니다.

gdscript
extends Node2D

const SPEED := 300.0

@onready var bad: Sprite2D = $Bad
@onready var good: Sprite2D = $Good

func _process(delta: float) -> void:
	bad.position.x += 5.0  # 프레임당 5픽셀
	good.position.x += SPEED * delta # 초당 300픽셀

	# 화면 밖으로 나가면 왼쪽 끝으로 되돌린다.
	if bad.position.x > 1000.0:
		bad.position.x = 0.0
	if good.position.x > 1000.0:
		good.position.x = 0.0
@onready 와 $ 기호
  • $Bad 는 get_node("Bad") 의 줄임입니다. 자식 노드를 이름으로 찾아옵니다.
  • @onready 는 "이 변수는 노드가 씬에 다 붙은 다음에 채워라"라는 뜻입니다.

@onready 없이 var bad := $Bad 라고 쓰면, 아직 자식이 트리에 붙기 전이라 null이 들어와 Invalid access to property on a null instance 오류가 납니다. 매우 흔한 실수입니다.

3) 프레임 제한을 걸고 관찰#

3강에서 만든 Load Level 부하 코드를 같이 켜거나, 아래 한 줄로 FPS를 직접 제한합니다.

gdscript
func _ready() -> void:
	Engine.max_fps = 20   # 일부러 20 FPS로 묶는다
직접 바꿔보기
  1. Engine.max_fps 를 60 → 30 → 20 → 10 으로 바꾸며 실행하세요.
  2. 두 스프라이트 중 어느 쪽이 화면을 가로지르는 시간이 변합니까?
  3. Bad는 프레임이 낮을수록 느려지고, Good은 끊겨 보이지만 시간은 같습니다.
  4. max_fps = 200 으로 올려보세요. 이번엔 Bad가 지나치게 빨라집니다.
여기까지 확인

Good이 항상 같은 시간에 도착하는 것을 눈으로 봤다면, 이 강의의 목적은 달성입니다.

delta를 곱하면 안 되는 것들#

여기서 초보가 반대 방향으로 실수합니다. 모든 곳에 delta를 곱하는 것입니다.

delta는 "시간에 비례해 변하는 값"에만 곱한다

곱해야 하는 것 — 시간당 변화량

  • 이동 속도: position += velocity * delta
  • 회전 속도: rotation += 2.0 * delta
  • 시간 감소: cooldown -= delta
  • 체력 재생: hp += 5.0 * delta

곱하면 안 되는 것 — 순간에 일어나는 일

  • 점프 시작: velocity.y = -400.0 (delta 곱하면 점프 높이가 프레임마다 달라짐)
  • 순간 이동: position = target
  • 데미지: hp -= 10 (한 대 맞은 건 한 대다)
  • 개수 세기: combo += 1
조금 애매한 경우 — 감속(마찰)

아래 코드는 초보가 자주 쓰는 감속입니다.

gdscript
velocity *= 0.9   # 매 프레임 10%씩 줄인다

문제가 보이나요? 프레임 수만큼 곱해지므로 60 FPS와 30 FPS에서 감속 속도가 다릅니다. 60 FPS면 1초에 0.9의 60제곱(≈0.0018), 30 FPS면 0.9의 30제곱(≈0.042)이 됩니다. 20배 넘게 차이납니다.

프레임에 무관한 감속은 이렇게 씁니다.

gdscript
# 초당 감속률 6.0. 프레임과 무관하게 같은 곡선.
velocity = velocity.lerp(Vector2.ZERO, 1.0 - exp(-6.0 * delta))

또는 물리 프레임(8강)을 쓰면 delta가 항상 고정이라 *= 0.9 도 안전해집니다. 이것이 물리 계산을 _physics_process에 넣는 실용적인 이유 중 하나입니다.

delta가 커지는 순간을 조심하기#

노트북 뚜껑을 닫았다 열거나, 브라우저 탭을 다른 곳으로 옮겼다가 돌아오면 delta가 갑자기 2초처럼 커질 수 있습니다. 그러면 이런 일이 벌어집니다.

  • 캐릭터가 벽을 뚫고 순간이동한다
  • 총알이 적을 그냥 통과한다

Godot은 이걸 막으려고 한 프레임에 처리할 물리 스텝 수를 제한합니다 (프로젝트 설정 → Physics → Common → Max Physics Steps Per Frame). 직접 만드는 로직에서도 필요하면 이렇게 막습니다.

gdscript
func _process(delta: float) -> void:
	# 한 프레임에 0.1초 이상은 없던 일로 한다.
	var dt := minf(delta, 0.1)
	position.x += SPEED * dt
  • 개발자 — _process(delta)의 delta와 _physics_process(delta)의 delta는 다른 값입니다. 뒤쪽은 항상 고정값(기본 1/60)입니다. 8강에서 다룹니다.
  • 기획자 — 수치를 적을 때 반드시 단위를 "초당"으로 명시하세요. "이동속도 5"는 개발자가 프레임당인지 초당인지 되물어야 하는 숫자입니다. "이동속도 300px/s"는 물어볼 필요가 없습니다.
  • 디자이너 — 애니메이션 재생 속도도 같은 원리입니다. "몇 프레임짜리 동작"보다 "몇 초짜리 동작" 으로 말해야 기기와 무관하게 같은 연출이 나옵니다.
자주 나는 오류

"_delta 라고 밑줄이 붙어 있던데요?" → 안 쓰는 매개변수 이름 앞에 밑줄을 붙이는 관례입니다. 경고를 없애는 용도이고 동작은 같습니다.

"Invalid access to property or key 'position' on a base object of type 'Nil'" → $노드이름 이 못 찾았습니다. 이름 철자와 부모/자식 관계를 확인하세요. @onready 를 빠뜨린 경우도 이 오류가 납니다.

"delta를 곱했는데도 속도가 이상하다" → _process와 _physics_process 양쪽에 같은 이동 코드를 넣어 두 번 움직이고 있을 확률이 높습니다.

여기까지 확인
  • delta는 "지난 프레임 이후 흐른 초"다
  • 속도는 항상 초당으로 정하고 delta를 곱한다
  • 점프 초기 속도나 데미지에는 delta를 곱하지 않는다
  • *= 0.9 식 감속이 프레임에 의존한다는 걸 안다

다음 강의 — 이제 "얼마나 움직이는가"는 해결됐습니다. 다음은 "어디로" 입니다. 좌표계, 원점, 피벗, 로컬과 글로벌을 다룹니다.