사전 게임 루프
패턴

게임 루프

gabury1고친 사람 github-actions[bot]

게임 루프는 플레이어가 아무것도 하지 않아도 게임 속 시간을 흘려보냅니다. 한 바퀴마다 입력을 읽어 게임 속 세계를 조금 움직이고 화면을 새로 그립니다. 이 반복이 게임이 켜져 있는 내내 돕니다. 그래서 한 바퀴를 어떤 박자로 돌리느냐가 이 구조의 핵심 문제입니다.

쉽고 빠른 이해

게임 루프는 게임이 켜져 있는 동안 멈추지 않고 도는 반복문입니다. 플레이어가 손을 떼고 있어도 공은 떨어지고 적은 걸어옵니다. 그 움직임을 만드는 것이 이 반복문입니다.

이게 없으면 게임은 입력이 올 때만 움직입니다. 요청이 와야 일하는 서버처럼 키를 누를 때만 화면이 바뀝니다.

한 바퀴는 이렇게 돕니다.

  1. 그 사이 들어온 입력을 읽습니다. 입력이 없으면 기다리지 않고 넘어갑니다
  2. 흐른 시간만큼 세계를 움직입니다
  3. 지금 모습을 화면에 한 장 그립니다

대가는 둘입니다. 할 일이 없어도 도는 만큼 컴퓨터가 쉬지 못합니다. 그리고 박자를 맞추는 코드가 붙습니다. 박자를 안 맞추면 성능이 높은 컴퓨터에서는 게임이 빨라집니다. 성능이 낮은 컴퓨터에서는 느려집니다.

버튼을 눌러야만 화면이 바뀌는 업무용 화면이나 턴제 게임에는 필요 없습니다.

상세

게임 루프는 백엔드 서버와 게임이 도는 방식의 차이에서 나왔습니다.

요청을 기다리는 프로그램과 멈추지 않는 프로그램

백엔드 서버는 대개 요청이 올 때까지 쉽니다. 요청을 받으면 처리하고 응답을 보낸 뒤 다음 요청을 기다립니다. 요청이 하나도 없으면 서버가 할 일도 없습니다.

게임은 그렇게 쉴 수 없습니다. 플레이어가 컨트롤러를 내려놓아도 비는 내리고 적은 다가옵니다. 세계가 입력과 상관없이 스스로 움직여야 합니다.

그래서 게임은 입력을 기다리는 대신 쉬지 않고 도는 반복문을 하나 둡니다. 이 반복문이 게임 루프(game loop)입니다. 게임이 시작될 때 들어가서 게임이 끝날 때 빠져나옵니다.

아래 그림은 두 반복을 나란히 세웁니다. 서버는 기다리는 단계에서 멈춥니다. 게임에는 멈추는 단계가 없습니다.

flowchart TD
    subgraph S["백엔드 서버"]
        S1{{"요청이 올 때까지 기다린다"}} --> S2["처리하고 응답을 보낸다"]
        S2 --> S1
    end
    subgraph G["게임"]
        G1["들어온 입력을 확인한다 · 없으면 넘어간다"] --> G2["세계를 조금 움직인다"]
        G2 --> G3["화면을 그린다"]
        G3 --> G1
    end
    S ~~~ G

한 바퀴의 모양

게임 루프의 한 바퀴는 대개 세 단계입니다. 입력 읽기, 상태 갱신, 그리기입니다. 아래는 가장 단순한 꼴입니다.

C
while (running) {
    readInput();   // 기다리지 않음
    update();      // 세계를 조금 움직임
    render();      // 화면에 한 장
}

세 단계가 각각 무엇을 하는지는 이렇습니다.

단계 하는 일 예
입력 읽기 지난 바퀴 뒤로 들어온 키 · 마우스 · 컨트롤러 입력을 거둔다 오른쪽 화살표가 눌려 있다
상태 갱신 입력과 흐른 시간을 보고 게임 속 세계를 한 번 움직인다 캐릭터를 오른쪽으로 옮기고 벽과 부딪혔는지 본다
그리기 지금 세계의 모습을 화면 한 장으로 만든다 옮긴 위치에 캐릭터를 그린다

입력 읽기는 블로킹하지 않습니다. 블로킹은 결과가 올 때까지 호출이 돌아오지 않고 멈춰 있는 것입니다. 입력이 올 때까지 멈추면 그 사이 세계도 멈춥니다. 그래서 게임은 들어온 입력만 확인하고 곧바로 다음 단계로 넘어갑니다. 백엔드의 논블로킹 입출력과 같은 생각입니다.

상태 갱신은 영어로 update 입니다. 게임 속 모든 물체의 위치 · 속도 · 체력 같은 값이 여기서 바뀝니다. 물체끼리 부딪혔는지 보는 충돌 감지도 이 단계에서 합니다.

그리기는 영어로 렌더링(rendering)입니다. 그린 결과 한 장을 프레임(frame)이라고 합니다. 화면에 보이는 움직임은 이 프레임이 빠르게 바뀌며 생깁니다.

화면이 1초에 새 그림으로 몇 번 바뀌는지를 세는 값이 프레임률입니다. 위의 단순한 루프는 한 바퀴에 프레임을 한 장 냅니다. 그래서 이 루프에서는 1초에 도는 바퀴 수가 곧 프레임률입니다.

박자를 안 맞추면 생기는 일

위의 단순한 루프는 CPU(Central Processing Unit, 중앙 처리 장치)가 허락하는 만큼 빨리 돕니다. 한 바퀴가 빨리 끝나면 곧바로 다음 바퀴에 들어갑니다. 그래서 컴퓨터가 빠를수록 1초에 도는 바퀴 수가 늘어납니다.

상태 갱신이 한 번에 캐릭터를 한 칸씩 옮긴다고 해 봅시다. 1초에 100바퀴 도는 컴퓨터에서는 캐릭터가 1초에 100칸 갑니다. 1초에 30바퀴 도는 컴퓨터에서는 30칸 갑니다. 같은 게임입니다. 그런데 컴퓨터에 따라 빠르기가 세 배 넘게 달라집니다.

그래서 게임 루프는 게임 속 시간이 흐르는 빠르기를 컴퓨터의 속도와 떼어 놓아야 합니다. 플레이어가 입력을 얼마나 자주 넣는지와도 떼어 놓아야 합니다. 그래야 어느 컴퓨터에서든 1초는 게임 속에서도 1초입니다.

박자를 맞추는 세 방법

방법을 견주려면 낱말 하나가 필요합니다. 타임스텝(timestep)은 상태 갱신 한 번이 게임 속 시간을 얼마만큼 앞으로 보내는지를 뜻합니다. 이 폭을 알아야 상태 갱신이 물체를 얼마나 옮길지 셈할 수 있습니다.

박자를 맞추는 방법은 크게 셋입니다. 셋은 타임스텝을 정하는 법이 다릅니다. 한 표로 견주면 이렇습니다.

방법 하는 일 약점
한 바퀴 시간을 고정하고 남으면 잠든다 예를 들어 한 바퀴에 60분의 1초를 쓰고, 일찍 끝나면 남은 시간만큼 잠든다 컴퓨터가 느려 60분의 1초를 넘기면 게임이 느려진다
가변 타임스텝 지난 바퀴에 걸린 시간을 재서 그만큼을 타임스텝으로 쓴다 물체가 벽을 건너뛸 수 있다 · 계산 결과가 컴퓨터마다 달라진다
고정 타임스텝 흐른 시간을 쌓아 두고, 늘 같은 타임스텝으로 상태 갱신을 여러 번 돌린다 코드가 가장 복잡하다

첫째 방법은 잠드는 동안 CPU를 쉬게 합니다. 대신 빠르기의 한쪽만 막습니다. 성능이 높은 컴퓨터는 잠들어 속도를 늦출 수 있습니다. 성능이 낮은 컴퓨터가 속도를 올릴 방법은 없습니다.

둘째 방법인 가변 타임스텝은 델타 타임(delta time)을 타임스텝으로 씁니다. 델타 타임은 지난 바퀴부터 이번 바퀴까지 흐른 시간입니다. 상태 갱신은 「속도 × 델타 타임」만큼 물체를 옮깁니다.

바퀴가 느리게 돌면 한 번에 많이 옮깁니다. 빠르게 돌면 조금씩 옮깁니다. 그래서 1초 동안 가는 거리는 같아집니다.

가변 타임스텝의 첫째 약점은 한 번에 옮기는 거리가 들쭉날쭉하다는 것입니다. 한 바퀴가 유난히 길어지면 속도가 큰 총알이 한 번에 벽 두께보다 멀리 옮겨집니다. 총알은 벽 앞에서 벽 뒤로 건너뜁니다. 그러면 부딪힌 적이 없는 것으로 계산됩니다.

둘째 약점은 같은 입력에도 계산 결과가 컴퓨터마다 달라진다는 것입니다. 소수점 계산에는 작은 오차가 붙습니다. 타임스텝이 바퀴마다 다르면 이 오차도 다르게 쌓입니다. 그래서 같은 입력을 넣어도 컴퓨터마다 결과가 조금씩 어긋납니다.

고정 타임스텝

셋째 방법은 갱신과 그리기의 박자를 가릅니다. 상태 갱신은 언제나 같은 간격으로만 돕니다. 그리기는 컴퓨터가 할 수 있는 만큼 합니다. 이 방식이 고정 타임스텝(fixed timestep)입니다.

상태 갱신이 도는 이 일정한 간격을 아래에서는 갱신 간격이라고 부릅니다. 고정 타임스텝에서는 갱신 간격이 곧 타임스텝입니다.

방법은 흐른 시간을 모아 두는 것입니다. 바퀴마다 흐른 시간을 쌓인 시간에 더합니다. 쌓인 시간이 갱신 간격 이상이면 상태 갱신을 한 번 돌리고 갱신 간격만큼 뺍니다. 모자라질 때까지 되풀이한 다음 그립니다.

그릴 때는 갱신에 다 못 쓰고 남은 시간도 함께 넘깁니다. 마지막 상태 갱신 전의 세계를 직전 상태, 갱신 뒤의 세계를 지금 상태라고 합시다. 남은 시간으로 두 상태 사이에서 물체를 그릴 위치를 셈합니다. 두 값 사이의 중간값을 셈해 채우는 이 일을 보간(interpolation)이라고 합니다.

남은 시간은 지금 상태보다 뒤에 흐른 시간입니다. 그래도 지금 상태 너머를 짐작해서 그리지 않습니다. 한 갱신 간격만큼 물러나 이미 계산된 두 상태 사이에 그립니다.

물러나는 까닭은 확정된 값만 쓰기 위해서입니다. 지금 상태 너머를 그리려면 다음 상태를 짐작해야 합니다. 짐작은 틀릴 수 있습니다. 두 상태 사이에 그리면 화면은 갱신 간격 하나만큼 늦는 대신 계산이 끝난 값만 보여 줍니다.

flowchart TD
    A["입력 읽기"] --> B["흐른 시간을 쌓인 시간에 더한다"]
    B --> C{"쌓인 시간이 갱신 간격 이상인가"}
    C -->|예| D["상태 갱신 한 번 · 쌓인 시간에서 갱신 간격을 뺀다"]
    D --> C
    C -->|아니오| E["그리기 · 남은 시간으로 보간한다"]
    E -->|다음 바퀴| A

그림에서 상태 갱신은 한 바퀴에 여러 번 돌 수도, 한 번도 안 돌 수도 있습니다. 컴퓨터가 느려 바퀴가 길어지면 갱신을 여러 번 몰아서 돌려 게임 속 시간을 따라잡습니다. 컴퓨터가 빨라 바퀴가 짧으면 갱신 없이 그리기만 합니다.

값을 넣어 한 바퀴를 따라가 봅니다. 시간은 1000분의 1초를 한 단위로 셉니다. 갱신 간격은 10, 처음 쌓인 시간은 0입니다. 지난 바퀴에는 25가 흘렀습니다.

쌓인 시간 += 25   // 쌓인 시간 = 25
update(10)       // 쌓인 시간 = 15
update(10)       // 쌓인 시간 = 5
render(5 / 10)   // 보간 비율 = 0.5

이 바퀴에서 상태 갱신은 두 번 돌고 5가 남습니다. 남은 5는 버리지 않고 다음 바퀴로 넘어갑니다. 그래서 오래 돌려도 게임 속 시간이 실제 시간에서 밀려나지 않습니다.

마지막 줄의 보간 비율 0.5 는 남은 5가 갱신 간격 10의 절반이라는 뜻입니다. 그래서 직전 상태와 지금 상태의 딱 중간 위치에 물체를 그립니다. 보간을 안 하면 물체는 상태 갱신이 돌 때만 옮겨집니다. 그러면 그리기가 더 자주 돌아도 움직임이 뚝뚝 끊겨 보입니다.

이 방식에서는 상태 갱신 한 번을 틱(tick)이라고도 합니다. 틱은 갱신 간격마다 한 번씩 옵니다. 프레임은 그리기마다 한 장씩 나옵니다. 둘의 수가 달라지는 것이 고정 타임스텝의 특징입니다.

고정 타임스텝이 밀릴 때

고정 타임스텝은 상태 갱신 한 번이 갱신 간격보다 빨리 끝난다고 믿습니다. 이 믿음이 깨지면 문제가 생깁니다. 앞의 셈처럼 갱신 간격을 10으로 잡았다고 해 봅시다. 갱신 한 번에 15가 걸린다면 따라잡으려고 갱신을 돌릴수록 쌓인 시간이 더 불어납니다.

갱신 한 번은 쌓인 시간에서 10을 뺍니다. 그 한 번을 도는 사이 15가 새로 흐릅니다. 아래 그림은 첫 바퀴에 쌓인 시간이 20이었다고 치고 세 바퀴를 따라갑니다. 셈을 쉽게 하려고 그리기에 드는 시간은 0으로 칩니다.

flowchart TD
    subgraph R1["바퀴 1"]
        A1["쌓인 시간 20 → 상태 갱신 2번"] --> B1["이 바퀴에 걸린 시간 30"]
    end
    subgraph R2["바퀴 2"]
        A2["쌓인 시간 30 → 상태 갱신 3번"] --> B2["이 바퀴에 걸린 시간 45"]
    end
    subgraph R3["바퀴 3"]
        A3["쌓인 시간 45 → 상태 갱신 4번 · 5 남음"] --> B3["이 바퀴에 걸린 시간 60"]
    end
    B1 --> A2
    B2 --> A3
    B3 --> N["다음 바퀴의 쌓인 시간 65 · 계속 불어난다"]

바퀴마다 갱신 횟수가 늡니다. 늘어난 갱신은 다음 바퀴에 더 많은 시간을 쌓습니다. 그래서 그리기와 그리기 사이가 30, 45, 60으로 벌어집니다.

끝내 루프는 갱신만 하다가 그리기에 거의 닿지 못합니다. 화면이 멈추고 게임이 얼어붙은 것처럼 보입니다. 이 되먹임을 흔히 죽음의 나선(spiral of death)이라고 부릅니다.

그래서 한 바퀴에 돌릴 갱신 횟수나 한 번에 더할 흐른 시간에 상한을 둡니다. 상한을 넘은 몫은 버립니다. 게임 속 시간이 잠깐 느리게 흐르는 대신 화면은 멈추지 않고 움직입니다.

그리기와 화면의 박자

그리기가 끝난 한 장은 화면에 내보내야 보입니다. 이때 수직 동기화(vertical synchronization)를 켜 두면 화면에 내보내는 호출이 바로 돌아오지 않습니다. 모니터가 다음 화면을 칠하기 시작할 때까지 기다렸다가 돌아옵니다.

모니터가 1초에 화면을 몇 번 새로 칠하는지를 세는 값이 주사율입니다. 수직 동기화가 켜져 있으면 게임 루프는 따로 잠들지 않아도 주사율에 발을 맞춥니다. 60 Hz(hertz, 헤르츠) 모니터라면 루프는 1초에 60바퀴 안팎으로 돕니다.

그러면 한 바퀴에 쓸 수 있는 시간은 60분의 1초입니다. 이 한도가 프레임 예산입니다. 한 바퀴가 이 예산을 넘기면 그 장은 다음 칠하기 때까지 밀립니다. 그동안 모니터는 앞 장을 한 번 더 칠합니다.

sequenceDiagram
    participant 루프 as 게임 루프
    participant 모니터
    루프->>모니터: 1장을 내보낸다
    Note over 모니터: 칠하기 1 · 1장이 보인다
    Note over 루프: 2장이 프레임 예산을 넘긴다
    Note over 모니터: 칠하기 2 · 1장을 한 번 더 칠한다
    루프->>모니터: 2장을 내보낸다
    Note over 모니터: 칠하기 3 · 2장이 보인다

그림에서 1장은 칠하기 두 번 동안 화면에 머뭅니다. 이렇게 화면이 바뀌는 간격이 고르지 않아지면 움직임이 끊겨 보입니다. 그래서 게임을 만드는 쪽은 한 바퀴의 시간을 밀리초(1000분의 1초) 단위로 잽니다.

게임 루프를 쓰는 곳과 안 쓰는 곳

게임 루프는 입력이 없어도 화면이 바뀌어야 하는 프로그램에 씁니다. 게임 말고도 시뮬레이션이 그렇습니다.

웹 브라우저에서는 requestAnimationFrame이 이 루프를 대신 돌려 줍니다. 브라우저가 화면을 새로 칠하기 직전마다 등록한 함수를 불러 줍니다. 그 함수 안에서 상태 갱신과 그리기를 합니다. 함수 끝에서는 다음 호출을 다시 등록합니다.

게임 엔진을 쓰면 루프를 개발자가 직접 쓰지 않습니다. 루프는 엔진이 쥐고 돌립니다. 엔진은 바퀴마다 물체의 갱신 함수를 불러 줍니다. 개발자는 그 갱신 함수만 채웁니다.

화면이 입력에 따라서만 바뀌는 프로그램에는 게임 루프가 필요 없습니다. 버튼을 눌러야 바뀌는 업무용 화면이나 한 수씩 두는 턴제 게임이 그렇습니다. 이런 프로그램은 입력이 올 때까지 쉬는 이벤트 루프로 충분합니다. 이벤트 루프는 할 일이 생길 때만 깨어납니다. 게임 루프는 할 일이 없어도 돕니다.

그래서 게임 루프의 대가는 둘입니다. 쉬지 않고 도는 만큼 CPU와 전력을 씁니다. 그리고 앞에서 본 박자 맞추기 코드가 붙습니다.

관련 항목

게임 루프가 한 바퀴에 거치는 처리 단계

입력 처리 · 상태 갱신 · 렌더링 · 버퍼 스왑 · 프레임 · 충돌 감지

게임 루프가 게임 속 시간을 나누는 방식

타임스텝 · 델타 타임 · 고정 타임스텝 · 가변 타임스텝 · 틱 · 물리 스텝 · 보간

게임 루프의 박자를 재는 지표

프레임률 · 프레임 시간 · 프레임 예산 · 주사율 · 입력 지연

게임 루프가 박자를 맞추는 화면 쪽 기법

수직 동기화 · 더블 버퍼링 · 트리플 버퍼링 · 가변 주사율

게임 루프가 밀릴 때 나는 장애

죽음의 나선 · 스터터링 · 프레임 드롭 · 화면 찢김

게임 루프와 맞세워지는 반복 구조

이벤트 루프 · 폴링 · 블로킹 · 논블로킹 입출력 · requestAnimationFrame

게임 루프를 대신 돌려 주는 실행 환경

게임 엔진 · 물리 엔진 · 업데이트 메서드 · 컴포넌트 패턴

게임 루프가 속하는 상위 분류

게임 개발 · 시뮬레이션 · 그래픽스 · 실시간 시스템

다른 이름: game loop · 게임루프 · 메인 루프 · main loop