본문 바로가기

블로그 / 기술

20Hz로 도는 게임을 144fps로 그리기, PallMark 렌더 보간

1초에 스무 번 계산하는 결정론 시뮬레이션을 매끄럽게 그리려고 만든 PallMark의 렌더 보간 이야기. 몬스터가 덜덜 떨던 원인 두 가지와, 그 원인을 숫자로 찾아낸 과정을 적었다.

Salmonholic ·· 5분 읽기

PallMark 지하묘지 층에서 해골 무리에 둘러싸인 플레이어와 머리 위로 떠오르는 피해 숫자

PallMark의 전투 규칙은 1초에 20번, 0.05초 간격으로 계산된다. Godot으로 만들던 원본과 같은 결정론 시뮬레이션을 그대로 옮겨 왔기 때문이다. 이 규칙 코드를 어떻게 옮겼고 무엇으로 검증했는지는 Godot에서 Unity로 옮긴 글에 적었다. 이 글은 그다음 문제를 다룬다. 계산은 1초에 20번뿐인데 화면은 144fps를 목표로 한다. 그 사이의 틈을 메우는 코드가 렌더 보간이다. 몇 줄짜리 코드인데 8월 말에 이틀 넘게 붙잡혀 있었다.

틱과 프레임 사이를 메우는 방법

시뮬레이션이 한 번 계산될 때마다(이하 한 틱) 모든 캐릭터의 권위 위치가 새로 나온다. 화면은 그 사이에도 여러 번 그려야 한다. 그래서 직전 틱 위치와 이번 틱 위치 사이를 선형으로 섞어 그린다. 섞는 비율 α는 “지난 틱 뒤로 흐른 시간 ÷ 0.05초”다.

원본에서 그대로 가져온 규칙이 세 가지 있다.

규칙 값 이유
섞는 방식 순수 선형 보간 이징을 넣으면 속도가 틱마다 출렁인다
화면에 쓰는 α 상한 1.0 다음 위치를 예측해서 앞서 그리지 않는다
내부 α 상한 1.25 프레임이 길게 멈춘 뒤 따라잡을 여유

예측(외삽)을 하지 않으니 화면은 시뮬레이션보다 최대 한 틱, 50ms 늦다. 대신 화면에 그린 위치는 언제나 시뮬레이션이 실제로 지나간 두 점 사이에 있다. 2026년 8월 28일 두 번째 클라이언트 커밋에서 20Hz 러너와 엔티티 보간을 처음 붙였고, 그날 PlayMode 시험으로 “화면 위치가 시뮬레이션 위치와 맞는가”를 확인했다. 그런데 시험을 통과한 것과 눈으로 보기에 매끄러운 것은 다른 문제였다.

첫 번째 떨림: 배우마다 따로 도는 시계

8월 30일, 플레이하면서 “적이 덜덜 떨린다”고 적은 게 벌써 세 번째였다. 이번에는 느낌으로 넘기지 않고 Windows 테스트 PC에서 30fps로 60초를 녹화하면서 캐릭터마다 프레임별 위치를 CSV로 남겼다. 그 기록에서 원인이 바로 보였다.

고치기 전과 후의 프레임별 이동 거리 비교 도식 위쪽 붉은 막대는 실제 녹화 기록의 값이다. 등속으로 걷는 몬스터인데 보폭이 1.5 : 2 : 1로 출렁였다.

13번 몬스터가 961~965틱 동안 등속으로 걸었는데, 프레임별 이동 거리는 0.053, 0.071, 0.036이 반복됐다. α 값을 보니 .667, .667, 1.0이 되풀이되고 있었다. 몬스터 행은 2,228행이 α=1.0이었고 .667은 59행뿐이었다. 보간 없이 틱 위치에 딱 붙어 그려지고 있었다는 뜻이다.

원인은 보간 시계를 배우(캐릭터)마다 따로 두고, 새 틱이 도착하면 그 시계를 0으로 되감은 데 있었다. 30fps 한 프레임은 틱의 2/3이다. 틱이 도착한 프레임에 남은 1/3을 버리면 위상이 매번 어긋난다. 원본 Godot 코드를 다시 보니 규칙은 단순했다. α는 “렌더 시각 − 틱 시각”을 틱 길이로 나눈 값이고, 틱 경계에서 되감지 않는다.

그래서 모든 배우가 공유하는 표현 시계를 하나 만들었다. 플레이어·몬스터·투사체가 한 프레임에 같은 α를 받는다. 30fps에서 α는 .667 → .333 → 1.0처럼 연속으로 흐른다. 고정 시드로 60틱 등속 이동을 30fps에 그리는 반증 시험을 넣었다. 통과 기준은 보폭 변동계수 0.10 이하로 잡았고, 수정한 코드는 0이 나왔다. 보간을 빼면 이 시험이 빨간불이 된다.

두 번째 떨림: 서 있는 몬스터

시계를 고치고 하루 뒤에 또 보고가 들어왔다. 이번에는 멈춰 선 캐릭터가 제자리에서 미세하게 흔들렸다. 이 증상을 보고한 것만 다섯 번째였다. 기록을 보면 각인병의 시뮬레이션 위치는 240~258프레임 내내 한 점에 고정돼 있었다. 그런데 화면 x좌표는 27.707, 27.786, 27.865를 돌고 있었다.

범인은 보간기 안의 “최적화” 한 줄이었다. 새 틱의 위치가 직전과 같으면 아무것도 하지 않고 돌아가게 되어 있었다. 그래서 캐릭터가 멈춘 뒤에도 직전 이동 구간(이전 위치, 다음 위치)이 그대로 남았다. 공유 시계의 α가 계속 돌면서 그 옛 구간을 앞뒤로 되풀이해 그리고 있었다.

같은 틱을 두 번 읽는 경우는 이미 앞단의 스냅샷 순번 검사가 걸러 주고 있었다. 그러니 이 조기 반환은 필요 없는 코드였다. 고친 규칙은 이렇다.

  • 새 틱이 오면 위치가 같아도 무조건 “이전 ← 다음, 다음 ← 새 위치”로 민다.
  • 이전과 다음이 같으면 섞지 않고 그 자리에 그린다.

녹화 CSV로 검사를 만들었다. 정지한 뒤에도 틱이 전진한 프레임에서 화면 이동이 0.02유닛을 넘는 표본을 센다. 수정 전 기록은 1,326건이었고, 같은 입력을 수정한 로직으로 다시 계산하니 0건이었다. 8월 31일 커밋의 제목도 이 숫자를 그대로 썼다.

층을 옮길 때와 멈출 때

보간이 끊겨야 하는 순간도 있다. 탑의 다음 층으로 넘어가면 같은 플레이어 ID가 전혀 다른 좌표에 나타난다. 이때 이전 층 좌표에서 새 좌표까지 보간하면 캐릭터가 화면을 가로질러 미끄러진다. 그래서 층이 새로 적재되면 표현 시계를 새 에포크로 초기화하고, 첫 프레임은 보간 없이 현재 위치에 바로 그린다. 순간이동 거리 임계값(권장 12유닛)도 만들어 두었다. 원본에 없는 규칙이라 기본값은 꺼 두었다.

반대로 화면만 잠깐 멈추는 히트스톱 때는 시뮬레이션이 계속 전진한다. 표현 시간이 권위 틱보다 뒤처지면 현재 틱 경계에서 다시 시작하게 해서, 한 번 생긴 지연이 계속 남지 않게 했다. 히트스톱을 어떻게 조정했는지는 타격감 글에 따로 적었다.

HUD의 체력·마나 구슬에서는 반대 결정을 했다. 9월 9일에 구슬 숫자의 보간을 없애고 값이 바로 반영되게 했다. 숫자는 매끄러운 것보다 정확한 것이 중요했다.

배운 것

이틀 동안 떨림을 두 번 “고쳤다”가 다시 보고를 받았다. 세 번째부터는 눈으로 보고 판단하지 않고, 프레임별 위치를 CSV로 남긴 뒤 숫자로 확인했다. 숫자를 보니 두 원인 모두 몇 분 만에 드러났다. 지금은 보간 관련 수정이 들어오면 녹화 기록에서 보폭 변동계수와 정지 떨림 건수를 같이 확인한다.

몬스터가 수십 마리 몰려와도 144fps를 지키는 이야기는 적 무리 성능 글에 이어진다. PallMark는 개발 중이며 Steam 출시를 준비하고 있다. 게임 소개는 PallMark 게임 페이지에 있다.

#PallMark#Unity#렌더 보간#결정론#20Hz#떨림

함께 읽으면 좋은 글

전체 글 →