게임 루프 렌더링에 관해 알아보기

게임 루프 구현을 위한 가장 일반적인 방법은 다음과 같습니다.

while (playing) {
    advance state by one frame
    render the new frame
    sleep until it’s time to do the next frame
}

여기에는 몇 가지 문제가 있으며, 가장 근본적인 문제는 게임에서 '프레임'을 정의할 수 있다는 생각입니다. 디스플레이마다 새로고침 속도가 다른 만큼 시간이 흐를수록 속도가 달라질 수 있습니다. 디스플레이에서 표시할 수 있는 속도보다 빠르게 프레임을 생성하면 가끔씩 하나를 드롭해야 합니다. 너무 느리게 생성하면 SurfaceFlinger가 획득해야 할 새 버퍼를 찾는 데 주기적으로 실패하고 이전 프레임을 다시 표시하게 됩니다. 두 경우 모두 눈에 띄는 문제를 야기할 수 있습니다.

렌더링 시 디스플레이의 프레임 속도에 맞추고, 이전 프레임 이후 경과한 시간에 따라 게임 상태를 진행시켜야 합니다. 여러 가지 방법이 있습니다.

  • Android 프레임 속도 라이브러리를 사용합니다(권장).
  • BufferQueue를 가득 채우고 '전환 버퍼' 백 프레셔를 사용합니다.
  • Choreographer(API 16 이상)를 사용합니다.

Android 프레임 속도 라이브러리

이 라이브러리 사용에 관한 자세한 내용은 적절한 프레임 속도 확보를 참고하세요.

대기열 채우기

아주 쉽게 구현할 수 있으며, 최대한 빨리 버퍼를 전환하기만 하면 됩니다. 이렇게 할 경우 Android 초기 버전에서는 실제로 SurfaceView#lockCanvas()로 인해 100ms에 걸쳐 절전 모드로 전환되는 페널티로 이어질 수 있었습니다. 이제는 BufferQueue로 속도가 조정되며, SurfaceFlinger가 감당 가능한 선에서 BufferQueue가 최대한 빨리 비게 됩니다.

이러한 접근 방식의 한 예는 Android Breakout에서 확인할 수 있습니다. Android Breakout은 애플리케이션의 onDrawFrame() 콜백을 호출한 다음 버퍼를 전환하는 루프에서 실행되는 GLSurfaceView를 사용합니다. BufferQueue가 가득 차면 버퍼 사용이 가능할 때까지 eglSwapBuffers() 호출이 대기합니다. 버퍼는 SurfaceFlinger에 의해 해제되었을 때 사용 가능하며, 디스플레이를 위한 새 버퍼가 획득되면 해제됩니다. 이 과정은 VSYNC에서 발생하므로 그리기 루프의 타이밍이 새로고침 속도와 일치합니다. 아주 사소한 차이는 있을 수 있습니다.

이러한 접근 방식에는 몇 가지 문제가 있습니다. 첫째, 앱이 SurfaceFlinger 활동에 묶입니다. 이 활동은 작업량 그리고 다른 프로세스와 CPU 시간을 놓고 경합하는지 여부에 따라 소요 시간이 다릅니다. 게임 상태는 버퍼 전환 간의 시간에 따라 진전되므로 애니메이션이 일관적인 속도로 업데이트되지 않습니다. 하지만 60fps로 실행할 경우 시간 경과에 따라 비일관적인 속도가 평균화되므로 끊김을 느낄 가능성은 적습니다.

둘째, BufferQueue가 아직 차지 않았으므로 처음 한두 번은 버퍼 전환이 매우 빠르게 이루어집니다. 프레임 간에 계산된 시간이 0에 가깝기 때문에 게임이 몇 개의 프레임만 생성하고 여기서는 아무런 일도 발생하지 않습니다. 새로고침할 때마다 화면을 업데이트하는 Breakout과 같은 게임에서는 게임이 처음 시작되거나 일시중지를 해제할 때를 제외하고는 대기열이 항상 가득 찹니다. 따라서 효과가 눈에 띄지 않습니다. 간혹 애니메이션을 일시중지한 다음 최대한 빠르게 되돌아가는 게임에서는 이상 증상이 보일 수도 있습니다.

Choreographer

Choreographer를 사용하면 다음 VSYNC에서 실행되는 콜백을 설정할 수 있습니다. 실제 VSYNC 시간은 인수로 전달됩니다. 따라서 앱의 절전 모드가 즉시 해제되지 않는 경우에도 디스플레이 새로고침 기간이 언제 시작될지 정확히 예측할 수 있습니다. 현재 시간 대신 이 값을 사용하면 게임 상태 업데이트 논리를 위한 일관적인 시간 출처를 얻을 수 있습니다.

안타깝지만 모든 VSYNC 이후에 콜백을 가져와도 콜백이 제시간에 실행되거나 충분히 빠르게 조치를 취할 수 있다는 보장은 없습니다. 앱은 속도가 뒤처지고 있는 상황을 감지하여 프레임을 수동으로 드롭해야 합니다.

Grafika의 'Record GL 앱' 활동을 예로 들 수 있습니다. Nexus 4 및 Nexus 5와 같은 일부 기기에서는 가만히 앉아서 보기만 할 경우 활동으로 인해 프레임이 드롭되기 시작합니다. GL 렌더링은 미미하지만 간혹 뷰 요소가 다시 그려질 때가 있으며, 기기가 전원 절약 모드로 떨어진 경우 측정/레이아웃 전달에 아주 긴 시간이 소요될 수 있습니다. systrace에 따르면 Android 4.4의 클록이 느려진 후에는 6ms 대신 28ms가 소요된다고 합니다. 화면 주변에서 손가락을 끌면 사용자가 활동과 상호작용한다고 생각하기 때문에 클록 속도가 높게 유지되고 프레임이 드롭되지 않습니다.

간단한 해결 방법은 현재 시간이 VSYNC 시간 이후의 N 밀리초보다 길 경우 Choreographer 콜백에서 프레임을 드롭하는 것이었습니다. N 값은 이전에 관찰된 VSYNC 간격에 따라 결정되는 것이 좋습니다. 예를 들어 새로고침 기간이 16.7ms(60fps)인 경우에는 15ms 이상으로 늦게 실행 중인 경우 프레임을 드롭하는 것이 좋습니다.

'Record GL 앱'이 실행되는 모습을 보면 드롭된 프레임 카운터가 증가하는 것은 물론 심지어는 프레임이 드롭될 때 테두리에서 빨갛게 깜박이는 모습까지 볼 수 있습니다. 시력이 아주 좋지 않고서는 애니메이션의 끊김 현상을 볼 수 없습니다. 60fps에서는 앱에서 가끔 아무도 모르게 프레임을 드롭할 수 있습니다. 단, 애니메이션이 계속해서 일정한 속도로 진전해야 합니다. 어느 정도가 용인되는지는 그리고 있는 대상, 디스플레이 특성과 앱 사용자가 버벅거림을 얼마나 잘 감지하는지에 따라 어느 정도 영향을 받습니다.

스레드 관리

일반적으로 SurfaceView, GLSurfaceView 또는 TextureView로 렌더링하는 경우에는 이러한 렌더링을 전용 스레드에서 실행하는 것이 좋습니다. UI 스레드에서는 과한 작업이나 확정되지 않은 시간을 소비하는 작업을 실행하면 안 됩니다. 대신 게임용 스레드 두 개를 만듭니다. 게임 스레드와 렌더링 스레드입니다. 자세한 내용은 게임 성능 향상을 참고하세요.

Breakout 및 'Record GL 앱'은 전용 렌더기 스레드를 사용하며 이 스레드의 애니메이션 상태도 업데이트합니다. 게임 상태만 빠르게 업데이트할 수 있다면 이는 아주 합리적인 접근 방식입니다.

다른 게임은 게임 논리와 렌더링을 완전히 분리합니다. 100ms마다 블록 하나만 이동하는 단순한 게임이 있다면 이러한 작업만 실행하는 전용 스레드를 보유할 수 있습니다.

run() {
    Thread.sleep(100);
    synchronized (mLock) {
        moveBlock();
    }
}

고정된 클록에 절전 모드 시간을 기반하여 드리프트를 방지하는 것이 좋습니다. sleep()이 완벽하게 일관적이지 않고 moveBlock()은 0이 아닌 시간을 소비합니다. 무슨 뜻인지 아시겠죠?

절전 모드가 해제된 그리기 코드는 단순히 잠금을 가져오고 블록의 현재 위치를 가져온 다음 잠금을 해제하고 그리기를 실행합니다. 프레임 간 델타 시간에 따라 조금씩 움직이는 대신 한 개의 스레드가 상황을 진행하고 나머지 한 개의 스레드는 그리기가 시작되면 요소가 발생할 때마다 요소를 그립니다.

복잡성을 포함하는 장면의 경우 절전 모드 해제 시간을 기준으로 정렬된 향후 이벤트 목록을 생성하고 다음 이벤트가 발생할 때까지 절전 모드를 유지하는 것이 좋지만 개념에는 차이가 없습니다.