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

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

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

이 강의 목차 (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픽셀

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

자동차 계기판

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

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

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

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

1) 씬 구성#

main.tscnSprite2D를 두 개 놓고 이름을 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 와 $ 기호
  • $Badget_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 식 감속이 프레임에 의존한다는 걸 안다

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