안드로이드 스튜디오 NDK와 Vulkan - 2

2022. 10. 23. 11:42Vulkan/특별편

개요

이 글은 1편에서 그대로 이어집니다.

1편은 "이렇게 하니까 안드로이드 일부 기기에서 Vulkan이 돌아가더라" 하는 내용이었습니다. 이제 게임과 같은 응용을 만들기 위해서는 어떤 구조를 만들지를 생각해 보는 게 이 2편입니다(코드째로 달라질 메인 이슈는 음원재생과 입력). 즉, 엄밀히 말하면 여긴 Vulkan과는 직접적으로 관련 없는 내용이 더 많습니다. 2.3절 정도가 관련이 있겠네요.

 

1편을 쓸 당시에도 (코드 외에) 뭔가 딱 모여 있는 자료를 찾기가 어려웠는데 그건 약 2달 지난 지금도 마찬가지인 것 같습니다. 때문에 여기 나오는 모든 얘기는 별도로 명시하지 않은 경우 제 머리 속에서 나온 것이며 막 신뢰해도 될 말은 아님을 미리 일러 둡니다.

 

목차

1. 동기 통신

2. AGDK

  2.1. GameActivity, 새로 만들자

  2.2. 기본 루프

    2.2.1. app->onAppCmd

    2.2.2. 입력 처리

  2.3. 프레임 페이싱

3. 리소스

  3.1. 직접 처리하는 파일

  3.2. 소리 파일, 오보에

본문

1. 동기 통신

일단 Vulkan으로 GPU 가속 래스터화 프로그램을 Windows 또는 Linux 대상으로 만들 때를 떠올려 봅시다. 이때 시간에 따라서 진행되는 바는 다음과 같습니다.

GLFW가 해 주고 있었긴 합니다만, 창 시스템에서 리프레시 타임 간에 입력(마우스 움직임, 키 누름/뗌, 창 위치/크기 조절 등)을 모으는 것은 우리의 프로세스와는 별개로 돌아가고 있었습니다. 이벤트를 모은다는 점은 일반 안드로이드 응용도 마찬가지일 겁니다.

 

스튜디오를 기준으로 입력 이벤트는 콜백 함수를 통해 처리할 수 있는데, 위의 구조로 생각해 보면 UI 스레드 (보통은 MainActivity.java로 시작되는 것)에서는 입력 이벤트를 받아서 쌓아 두고, 큐 제출(vkQueuePresentKHR)로부터 수직 동기화가 이루어지는 시점에 이 이벤트를 주는 그림을 생각해 볼 수 있습니다. 이렇게 되겠네요.

보시면 thread 1은 쉬는 구간이 있습니다. 그렇지만 스레드를 만들었다 없앴다 하는 건 보통 부담이 되기 때문에 (Java 머신 레이어에서 그와 관련해 뭔가 되는가? 하면 전 모릅니다.) 리턴이 아닌 재우는 방식으로 구현하는 것이 낫다고 봅니다. 이 그림을 기준으로 기존의 한 번 렌더링하고 말았던 삼각형(안드로이드 스튜디오 프로젝트 폴더 다운로드)에 변화를 주며 계속 렌더링하는 프로그램을 간단히 구현해 보겠습니다. 간단하게 입력 받기는 건너뛰고(2장에서 다룹니다.) 색상값에 sin(시각)의 절댓값을 곱하는 것으로 가 보겠습니다. 참고로 기존의 SPIR-V로 컴파일하기 전의 셰이더 코드들은 이랬습니다.

 

// Vertex
#version 450
// #extension GL_KHR_vulkan_glsl: enable

layout(location = 0) in vec3 inPosition;
layout(location = 1) in vec3 inColor;

layout(location = 0) out vec3 fragColor;

vec2 positions[3] = vec2[](
    vec2(0.0, -0.5),
    vec2(-0.5, 0.5),
    vec2(0.5, 0.5)
);

vec3 colors[3] = vec3[](
    vec3(1.0, 0.0, 0.0),
    vec3(0.0, 1.0, 0.0),
    vec3(0.0, 0.0, 1.0)
);

void main() {
    gl_Position = vec4(inPosition, 1.0);
    fragColor = inColor;
}
// Fragment
#version 450

layout(location = 0) in vec3 fragColor;

layout(location = 0) out vec4 outColor;

void main() {
    outColor = vec4(fragColor, 1.0);
}

 

글을 읽는 여러분은 Vulkan의 기본 사용법은 알고 있을 거라고 가정하고 있기 때문에 구체적인 방법은 생략하겠습니다만 푸시 상수(정말 모르면 4편 참고)를 이용하는 게 가장 간단하고 성능도 좋습니다.

 

그건 알아서 쉽게 하실 수 있고, 핵심은 여기입니다. 새로이 drawFrame이란 함수를 썼는데, 그냥 원래의 fixedDraw에서 뺀 겁니다.

 

@Override
public void surfaceCreated(@NonNull SurfaceHolder surfaceHolder) {
    Log.d("java", "created");
    Rect frm = surfaceHolder.getSurfaceFrame();
    synchronized (lock){
        initVk(surfaceHolder.getSurface(),frm.width(),frm.height());
    }
    if(vkThread==null){
        signaller = new Runnable(){
            @Override
            public void run(){
                synchronized (lock){
                    // set input queue
                    lock.notify();
                }
            }
        };
        vkThread = new Thread(new Runnable() {
            @Override
            public void run() {
                while(true){ // 종료 조건은 알아서..
                    try {
                        synchronized (lock){
                            runOnUiThread(signaller);
                            lock.wait();
                        }
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    drawFrame();
                }
                finalizeVk();
            }
        });
        vkThread.start();
    }
}

 

(새로 생긴 객체인 vkThread는 그냥 Thread 객체이며 lock은 그냥 Object입니다.) 푸시 상수 세팅을 제대로 하셨다면 삼각형이 어두워졌다 밝아졌다를 계속 반복하고 있을 겁니다. 이 코드에서는 lock.notify를 하는 중에 화면이 회전하는 경우 부적절한 제출을 막는 게 불가능합니다. 그저 Vulkan 단에서 스왑체인이 suboptimal 혹은 out of date인 경우 알아서 포기하게 만드는 걸 더 권장합니다. 어차피 회전하는 동안 한 프레임 안 그려지는 건 실질적으로 영향이 없다시피합니다.

 

* 개발자 옵션에서 게임 -> GPUWatch를 활성화하고 확인할 앱 NdkStart를, API 렌더링을 Vulkan으로 세팅하고 앱을 실행하면 FPS, CPU/GPU 사용량, 정점/조각 셰이더 로드를 표시해줍니다.

제 기기의 화면 자체 주사율은 최대 60Hz인데 특이하게 30FPS를 꾸준히 유지하고 있네요. 이것은 아래 2.3절에 나오지만  FIFO인데 그래픽스 큐와 표시 큐가 같은 계열이라서 graphics와 present 사이에 wait을 하면 시간을 추가로 먹기 때문으로 추정됩니다.

원인을 확인하기 위해 공식 문서를 적당히 찾아보았는데, 이런 걸 발견했습니다. 혹시 UI 스레드에 들어온 이벤트 큐 핸들링의 딜레이가 Vsync 타이밍 하나를 잡아먹고도 남는지도 모르죠. 내용은 2장으로 이어집니다.

 

Add frame pacing functions  |  Android Developers

Add frame pacing functions Stay organized with collections Save and categorize content based on your preferences. Use the following functions to use Android Frame Pacing with a rendering engine based on the Vulkan API. Identify required extensions for crea

developer.android.com

여기까지의 코드, 별로 바뀐 건 없지만 올렸습니다. 설명이 너무 적어서 따라가지지 않는다면 참고해 주세요.

NdkStart.zip
0.56MB

 

2. AGDK(Android Game Development Kit)

https://developer.android.com/games/develop/custom/overview

 

Customizing or porting game engines  |  Android Developers

Customizing or porting game engines Stay organized with collections Save and categorize content based on your preferences. If you're using C or C++ to develop or customize a game engine, the following requirements are critical to integrating Android suppor

developer.android.com

난 지금까지 무얼 한 거지? 왜 이제야 발견했는지 모르겠지만, 이제라도 발견했으니 적당히 활용해 보는 게 좋겠군요. 지금까지처럼 이어가도 불가능하지 않긴 하겠습니다만, 아무튼 게임은 프로그램적으로 성능이 최우선이니까요, 앱의 UI 파트와 게임 엔진 파트를 이어주는 부분이 알아서 와 준다면 고마운 거죠.

 

* Visual Studio 환경을 그대로 사용하는 경우, 안드로이드를 타겟으로 하는 AGDE라는 확장을 사용할 수 있습니다. 하지만 여기서는 플레이 플랫폼뿐 아니라 개발 플랫폼 역시 Windows에 종속시키지 않고자 하기 때문에, 이건 생략합니다.

 

 

2.1. GameActivity, 새로 만들자

 

엔진에는 GameActivity를 사용하는 것이 가장 좋은 선택이라고 합니다. 이를 위해 AGDK를 다운로드합니다. 참고로 약관을 요약하면 이렇습니다. 그저 주는 대로 얌전히 쓰면 유혈 사태는 일어나지 않을 겁니다. (아마 확인은 안 했었지만 스튜디오 자체도 그런 약관일 겁니다.)

 

- 로열티 없음. 서브라이센스 불가.

- SDK 수정/역엔지니어링 불가.

- 구글 이름, 상표, 로고 등 사용 불가.

- SDK 자체가 안드로이드 외의 플랫폼에 대한 개발에 직접적으로 사용될 수 없음. 예를 들어 Windows, Linux, Android 대상의 게임엔진을 만든다면 이 SDK가 Windows, Linux 부분 구현에서 쓰이지 않아야 합니다.

 

이왕 하는 거 새 프로젝트를 만들어서 해 보죠. 일단 빈 프로젝트를 만들어 주세요. AGDK를 개발자 안내 페이지에서 받을 수도 있지만 문서에서는 Jetpack을 통해 다운로드하는 걸 강조하고 있고, 그러는 경우 저도 굳이 github 리포에 그걸 넣지 않더라도 종속성을 주지 않을 수 있기 때문에 그 방법을 선택하겠습니다.

 

build.gradle에 다음 내용을 추가합니다.

 

android {
    ...
    // 안드로이드 프레임 페이싱과 성능 튜너 라이브러리를 사용하기 위해 네이티브
    // 의존성을 불러올 수 있게 합니다. 이들은 CMake에서 "games-frame-pacing", "games-performance-tuner"
    // 라는 패키지로 끌어올 수 있게 됩니다.
    buildFeatures {
        prefab true
    }
}

dependencies {
    // 프레임페이싱 라이브러리
    implementation "androidx.games:games-frame-pacing:1.10.1"

    // 성능 튜너
    implementation "androidx.games:games-performance-tuner:1.6.0"

    // 게임 액티비티 라이브러리
    implementation "androidx.games:games-activity:1.2.1"

    // 게임 컨트롤러 라이브러리
    implementation "androidx.games:games-controller:1.1.0"

    // 게임 텍스트 입력 라이브러리. games-activity와 같이 쓰는 거 아니라고 합니다.
    //implementation "androidx.games:games-text-input:1.1.1"
}

 

gradle.properties에서는

android.useAndroidX=true
android.prefabVersion=2.0.0

가 있는지 확인해 주세요. 4.0버전 이하에서는

android.enablePrefab=true

도 있어야 한다고 합니다.

 

그 다음 왼쪽 탐색기의 Android 뷰에서, app을 우클릭, Add C++ to Module을 선택합니다. 선택지 중에는 새로 자동 위치에 CMakeLists.txt를 만드는 걸 고르면 됩니다.

 

 

새로 생긴 CMakeLists.txt에 다음을 추가합니다.

find_package(game-activity REQUIRED CONFIG)
find_package(games-frame-pacing REQUIRED CONFIG)
find_package(games-performance-tuner REQUIRED CONFIG)

 

같은 파일의 target_link_libraries에는 다음도 추가합니다. 그리고 상단에 작게 나온 sync now를 누릅니다.

android
vulkan
game-activity::game-activity
games-frame-pacing::swappy_static
games-performance-tuner::tuningfork_static

 

이제 AGDK 라이브러리를 쓸 수는 있습니다. 또 다시 app을 우클릭하여 빈 액티비티를 만들어 주고, AndroidManifest.xml에서  새로 생긴 activity 태그를 다음과 같이 고쳐 시작 액티비티로 설정합니다.

<activity
            android:name=".MainActivity"
            android:exported="true">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />

                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
            <!--아래 메타데이터의 value값은 여러분의 CMakeLists.txt에서
            add_library에 있었던 이름을 주면 됩니다. 그 값은 기본적으로
            여러분 프로젝트의 이름과 동일합니다.-->
            <meta-data android:name="android.app.lib.name" android:value="avk"/>
        </activity>

그 다음 GameActivity(com.google.androidgamesdk.GameActivity)를 상속하게 바꾸고 네이티브와 이어주어야 하므로 코드를 추가하여, 대략 다음과 같은 상태가 될 겁니다.

 

public class MainActivity extends GameActivity {
    static {
       System.loadLibrary("avk"); // 여러분 프로젝트 이름
    }

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main); // 사실 이 줄은.. 더 아래를 참고
    }
}

 

그 다음 기본으로 생성된 cpp 파일에 이 내용을 일단 그대로 넣어 줍니다. 이거 없으면 GameActivity 자체가 실행이 안 돼요.

#include <game-activity/native_app_glue/android_native_app_glue.h>

#include <game-activity/GameActivity.cpp>
#include <game-text-input/gametextinput.cpp>
extern "C" {
#include <game-activity/native_app_glue/android_native_app_glue.c>
}

extern "C" {
void android_main(struct android_app* state);
};

void android_main(struct android_app* app) {
    // 우리의 게임엔진 시작 파트가 들어감
}

 

이 상태에서 빌드해서 실행해 보면 아무것도 없는 흰 화면만 나올 겁니다. 눈치가 제법 있다면 저 android_app 구조체를 통해 창 표면을 비롯하여 입력 등 안드로이드 운영체제 상의 이벤트와 상호작용할 수 있을 걸 예상했을 겁니다. 시그니처를 보시면 알겠지만 C식의 인터페이스이며(멤버함수 없음), 레퍼런스는 여기를 보시면 되는데, 아래에 관련 내용을 짧게 정리했습니다.

 

멤버 설명 비고
GameActivity* activity GameActivity입니다. 1편 보셨음 알겠지만 아마 특별히 직접적으로 쓸 일 없을 겁니다.  
int activityState Activity의 상태입니다. APP_CMD_START, APP_CMD_RESUME, APP_CMD_PAUSE, or APP_CMD_STOP이 가능합니다.  APP_CMD_XXX는 NativeAppGlueAppCmd 열거형에 있는 값이며 저 값들 말고 여러 가지가 더 있습니다. 다만 나머지 값들은 여기 쓰이는 용도는 아니라고 보는 게 맞을 겁니다.
AConfiguration* config 앱의 구성입니다. AConfiguration_getXXX 이라는 이름의 함수들에 매개변수를 주어 원하는 값을 알아냅니다. 첫 매개변수는 AConfiguration*로 고정이며, 리턴값 혹은 포인터를 통한 값 대입을 읽으면 됩니다. 예를 들어 언어 세팅 등이 있습니다.
특별히 엔진 상의 용도를 찾기보다는 좌측의 설명된 함수를 중계하여 게임 개발자가 사용하도록 제공하는 게 낫겠네요.
ARect contentRect 현재 창의 영역입니다. ARect는 uint32 bottom, left, right, up로 구성됩니다.
int currentInputBuffer 설명이 안 써 있는데 inputBuffers에서 유효한 값의 수일 겁니다.   
int destroyRequested 이 값이 0이 아닌 경우라면 GameActivity가 종료된다는 의미입니다.  
android_input_buffer inputBuffers[] 여기로 입력 이벤트가 들어옵니다. 안에는
GameActivitykeyEvent keyEvents[]
uint64_t keyEventsCount
GameActivityMotionEvent motionEvents[]
uint64_t motionEventsCount
가 있습니다.
keyEvent(1) (2)의 경우 action과 keyCode 정도를 보시면 됩니다.
motionEvent (1) (2)의 경우 action, pointers 정도를 보시면 됩니다.
ALooper* looper 앱의 스레드에 연관된 ALooper입니다. 이벤트 루프를 추적하는 상태라고 합니다. 문서.
이벤트를 폴링하는 ALooper_pollAll과 pollOnce를 참고하면 될 것 같아요.특이한 점은 이 함수가 ALooper 객체를 받지 않는다는 건데, fd와 관련 있는가 봅니다.
onAppCmd(struct android_app* app, int32_t cmd) 앱 명령에 대응하여 처리하는 콜백 함수포인터입니다. NativeAppGlueAppCmd 참고하세요.
void* savedState 생성 시점에 주어진 저장 상태입니다. 원하는 대로 사용할 수 있긴 한데, 사용법은 문서를 참고하세요.
size_t savedStateSize savedState의 메모리 상 크기입니다.  
int texInputState 이 값이 1인 경우 텍스트 입력이 있었다는 뜻입니다. 1인 경우 GameActivity_getTextInputState 함수를 사용하여 정보를 얻습니다.  
void* userData 자유롭게 사용하면 됩니다. 예를 들면 C식으로 프로그래밍을 할 때 onAppCmd에 주어진 이 구조체를 넘겨 여러분이 정의한 데이터에 접근할 수 있습니다.
ANativeWindow* window 창 표면입니다. 사용법은 1편을 참고하세요.

IDE에 따르면 다른 멤버도 있긴 한 모양인데 문서가 우리한테서는 숨기고 싶은 모양입니다. (C struct 자체는 정보은닉이 없으니)

아무튼 이 내용과 다음 코드를 쓰고 프로그램을 실행해 로그를 확인해 보면, 안드로이드 UI 스레드와 네이티브 단이 잘 공존하고 있음을 잘 확인할 수 있습니다.

void android_main(struct android_app* app) {
    // init sth
    while(true){
        int events;
        android_poll_source* source;

        while(ALooper_pollAll(0,nullptr,&events,(void**)&source) >= 0){
            if(source!=nullptr){
                source->process(source->app, source);
            }
            if(app->destroyRequested){
                break;
            }
        }
        if(app->destroyRequested){
            break;
        }
        // render sth
    }
}

물론 위 프로그램은 아무것도 하지 않습니다. 해당 튜토리얼에서 그래픽스를 세팅하는 부분(eglCreateWindowSurface)을 보면 app->window가 이미 있는 걸 가정하여 surface를 사용하는데요, 이 글의 과정을 따라서 실제로 하면 처음에는 app->window가 nullptr인 상태이며 이는 가만히 놔두면 전혀 변하지 않습니다. 이유는 인터넷에서 도저히 찾을 수가 없었는데요, 결론부터 말하면 뭘 추가해야 할 게 아니라 빈 Activity 생성과 동시에 자동으로 들어갔던 java 코드 중 아래와 같은 부분을 없애야 합니다.

 

setContentView(R.layout.activity_main);

 

이 라인을 없애면 알아서 네이티브 window, 즉 SurfaceView를 만들어줍니다. 이건 GameActivity가 알아서 하는 일이었기 때문인 것 같아요. 별도의 뷰를 넣어서 특별히 조정하고 싶다면(ex: 게임 콘솔 같은 버튼) Java 코드 내에서 할 수 있는지는 나중에 필요하다면 알아보겠습니다.

 

2.2. 기본 루프

위 내용을 활용하여 간단히 Vulkan 프로그램을 또 만들어 줍니다. 본격적으로 엔진 프로젝트를 시작한 게 아니라서 이전 프로그램과 거~의 동일합니다. 단지 스레드 시작 및 이벤트 폴링 부분을 GameActivity에 맡겼죠.

 

굳이 확인하지 않아도 ALooper_pollAll은 새로이 들어온 이벤트를 android_poll_source 객체를 할당하여 넣고 돌려줄 겁니다. 2,3번째 매개변수는 문서의 코드에선 사용하고 있지 않네요. 용도가 궁금하면 여기를 확인해 주세요. 지금은 볼 필요 없는 내용 같습니다. 아무튼 위의 source->process 부분에서 이벤트에 대한 콜백인 app->onAppCmd를 호출합니다.

 

2.2.1. app->onAppCmd

아래의 스켈레톤 switch문을 참고하면 좋을 것 같습니다.

// 콜백 등록: app->onAppCmd = handleCmd;
static void handleCmd(android_app* app, int32_t cmd){
    switch (cmd) {
        case NativeAppGlueAppCmd::APP_CMD_CONFIG_CHANGED:
            // 장치 구성 속성이 변경된 경우
            break;
        case NativeAppGlueAppCmd::APP_CMD_CONTENT_RECT_CHANGED:
            // 창의 표시 영역이 변경된 경우 (app->contentRect 확인)
            break;
        case NativeAppGlueAppCmd::APP_CMD_DESTROY:
            // 액티비티 종료됨.
            break;
        case NativeAppGlueAppCmd::APP_CMD_GAINED_FOCUS:
            // 액티비티가 포커스를 받은 경우.
            break;
        case NativeAppGlueAppCmd::APP_CMD_INIT_WINDOW:
            // app->window를 통해 창 표면을 사용할 수 있게 됨.
            break;
        case NativeAppGlueAppCmd::APP_CMD_LOST_FOCUS:
            // 액티비티가 포커스를 잃은 경우.
            break;
        case NativeAppGlueAppCmd::APP_CMD_LOW_MEMORY:
            // 메모리가 부족하다는 경고라고 합니다.
            break;
        case NativeAppGlueAppCmd::APP_CMD_PAUSE:
            // 액티비티가 중지된 경우.
            break;
        case NativeAppGlueAppCmd::APP_CMD_RESUME:
            // 액티비티가 재개된 경우.
            break;
        case NativeAppGlueAppCmd::APP_CMD_SAVE_STATE:
            // 여기서는 여러분이 만든 임의의 컨텍스트를 저장해 두는 게 좋습니다.
            // app->savedState를 활용하고, app->savedStateSize를 세팅합니다.
            break;
        case NativeAppGlueAppCmd::APP_CMD_START:
            // 액티비티가 시작된 경우.
            break;
        case NativeAppGlueAppCmd::APP_CMD_STOP:
            // 액티비티가 정지된 경우.
            break;
        case NativeAppGlueAppCmd::APP_CMD_TERM_WINDOW:
            // window가 종료되려는 경우.
            break;
        case NativeAppGlueAppCmd::APP_CMD_WINDOW_INSETS_CHANGED:
            // Window 인셋이 변경된 경우.
            break;
        case NativeAppGlueAppCmd::APP_CMD_WINDOW_REDRAW_NEEDED:
            // ANativeWindow를 깔끔하게 다시 그려야 합니다.
            break;
        case NativeAppGlueAppCmd::APP_CMD_WINDOW_RESIZED:
            // ANativeWindow 크기가 변한 경우.
            break;
        case NativeAppGlueAppCmd::UNUSED_APP_CMD_INPUT_CHANGED:
            // Unused. Reserved for future use when usage of AInputQueue will be supported
            break;
        default:
            break;
    }
}

 

보통의 경우 중에서 어떤 경우에 어떤 이벤트를 받는지 로그를 찍어 알아보죠. 이것은 저의 기기인 Galaxy Note 9가 기준입니다. 누락되었거나 다른 장치에서 있을 수 있는 일에 대해 어떤 이벤트가 발생하는지는 직접 알아보길 권장합니다.

상황 명령들 비고
처음 시작 start -> resume -> window inset change -> init window -> window resize -> redraw needed -> gained focus  
볼륨 조절 및 그 볼륨 조절 창을 펼침 (없음)  
홈 키를 눌러 내림 pause -> lost focus -> window terminate -> low memory -> stop -> save state  
(위에 이어) 내린 창 복구 start -> resume -> init window -> window resize -> redraw needed -> gained focus 스왑체인 재생성은 redraw needed 시점이 가장 적절하겠군요.
프로세스들 펼치는 것 (노트9 기준 좌측 하단의 가상버튼, 조금 구형 폰에서는 홈키를 길게 눌렀을 때입니다만 거기들에서 이 글을 쓴 이유인 벌칸이 돌아갈 리가..) 홈 키와 동일  
상단 메뉴(시계가 나오는 곳) 혹은 측면 메뉴를 잡아당김 lost focus 화면 아래까지 펼쳐도 변하지 않습니다.
(위에 이어) 메뉴를 되돌림 gained focus  
홀드 pause -> window terminate -> stop -> save state -> low memory -> lost focus  
(위에 이어) 홀드 해제 (홈 이후 복구와 동일)
start -> resume -> init window -> window resized -> redraw needed -> gained focus
 
상단 메뉴에서 설정 등을 눌러 다른 곳으로 빠져나감 (홀드와 거의 동일. focus는 이미 잃었기 때문)
pause -> window terminate -> stop -> save state -> low memory
 
(위에 이어) 원래 있던 곳으로 홀드 해제와 동일  
화면 회전 pause -> window terminate -> stop -> save state -> activity destroy -> start -> resume -> inset changed -> init window -> window resize -> redraw needed -> focused 화면을 회전하면 Activity가 없어진다는 특성 때문에, android_main 자체가 재시작될 수 있다는 점에 유의하세요. 프로세스는 종료되지 않으므로 C++에서 접근할 수 있는 주소 공간의 데이터는 남아 있습니다.
펜을 뽑아 S펜 메뉴를 엶 (없음)  
스크린샷 촬영 찍은 이미지 보기로 넘어가면서 lost focus, 그대로 돌아오면 gained focus  

 

(2023.1.8 내용 추가) 화면 회전 시 액티비티가 없어지고 재생성되는 것은 AndroidMenifest.xml 파일에서 activity 태그에 다음 속성을 넣으면 화면이 회전해도 액티비티가 재생성되지 않도록 할 수 있습니다.

android:configChanges="orientation|screenSize"

이 경우, window resize와 redraw needed 이벤트가 모두 발생하긴 하지만 1편에서 언급했던 스왑체인 사전 회전을 신경 쓸 이유가 생기는데요, 여기에 친절하게 설명되어 있으니 참고하시기 바랍니다. 요약하자면 지금까지 설명한 것과 다르게 스왑체인 재생성 자체는 하지만 차원은 변경하지 않고, 오직 surface에서 다시 얻은 preTransform만 바꾸어 넣은 후, 응용 프로그램 내에서도 해당 부분을 반영합니다.

 

2.2.2. 입력 처리

android_app_set_motion_event_filter(터치/모션), android_app_set_key_event_filter를 통해 입력에 대한 필터를 세팅할 수 있습니다. false를 리턴하는 경우에 대해서는 이후에 직접 처리할 일이 없어집니다.

// 등록: android_app_set_motion_event_filter(app,onMotion);
// 이와 비슷하게 key 필터의 시그니처는 void(const GameActivityKeyEvent*)입니다.
bool onMotion(const GameActivityMotionEvent* act){
    // false를 리턴하는 경우 시스템이 이벤트를 처리합니다. true를 리턴하는 경우 android_native_app_glue가 처리합니다.
    return act->source == SOURCE_TOUCHSCREEN; // motion filter를 세팅하지 않았을 때의 동작과 동일
}

 

실제 그 사이에 받은 입력을 들여다보는 것은 다음 코드를 통해 할 수 있습니다.

android_input_buffer* inputBuffer = android_app_swap_input_buffers(app);

이것을 하면 위 android_app 구조체 설명에서 android_input_buffer를 사용할 수 있습니다. 리턴된 포인터는 그 배열의 원소 중에서 지금 들어온 녀석의 주소입니다. (그러니 해제하지 않아도 됩니다.) 처리할 이벤트가 없으면 nullptr가 리턴됩니다.

 

// process 이후에 불러주세요.
void onInput(android_app* app){
    android_input_buffer* inputs=android_app_swap_input_buffers(app);
    if(inputs){
        for(uint64_t i=0;i<inputs->motionEventsCount;i++){
            GameActivityMotionEvent& ev = inputs->motionEvents[i];
            switch (ev.action & AMOTION_EVENT_ACTION_MASK) {
                case AMOTION_EVENT_ACTION_DOWN:
                    // if(ev.pointerCount) ev.pointers[0].rawX;
                    break;
                case AMOTION_EVENT_ACTION_UP:
                    break;
                case AMOTION_EVENT_ACTION_MOVE:
                    break;
                default:
                    // 참고: https://developer.android.com/reference/android/view/MotionEvent#constants_1
                    break;
            }
        }
        for(uint64_t i=0;i<inputs->keyEventsCount;i++){
            GameActivityKeyEvent& ev = inputs->keyEvents[i];
            switch (ev.action) {
                case AKEY_EVENT_ACTION_DOWN:
                    // ev.keyCode
                    // ev.unicodeChar
                    // AKEYCODE_X
                    break;
                case AKEY_EVENT_ACTION_UP:
                    break;
                case AKEY_EVENT_ACTION_MULTIPLE:
                    // Multiple duplicate key events have occurred in a row,
                    // or a complex string is being delivered.
                    // The repeat_count property of the key event contains the number of times the given key code should be executed.
                    break;
                default:
                    // 참고: https://developer.android.com/reference/android/view/KeyEvent#constants_1
                    break;
            }
        }
        // 아래를 호출하지 않으면 이벤트 버퍼가 가득 찹니다.
        android_app_clear_key_events(inputs);
        android_app_clear_motion_events(inputs);
    }
}

 

꽤나 직관적으로요, 대부분의 입력은 터치고, 기본적인 필터를 사용하시는 경우 아래 3개 가상 버튼과 좌/우측의 볼륨, 홀드, 그리고 (경우에 따라) 빅스비 버튼 중에서는 취소 버튼(아래 3개 가상 버튼 중 오른쪽)만 들어옵니다.

 

대충 GLFW에서 해 주던 건 거의 다 본 것 같으니, 1편의 프로그램을 연결해 보죠. 어렵지 않습니다. 모두 보여드리는 건 프로젝트째로 올리는 걸로 대신하고, 중요한 부분만 보면 아래 2개 함수입니다.

 

static void handleCmd(android_app* app, int32_t cmd){
    static bool isFirst=true;
    switch (cmd) {
        case NativeAppGlueAppCmd::APP_CMD_TERM_WINDOW:
            // window가 종료되려는 경우.
            onart::VkPlayer::hwnd=nullptr; // 어차피 여기서 REDRAW_NEEDED 사이에는 큐 제출이 제대로 안 되므로 일단 이걸 표식으로 함.
            LOG_WITH("window terminate");
            break;
        case NativeAppGlueAppCmd::APP_CMD_WINDOW_REDRAW_NEEDED:
            // ANativeWindow를 깔끔하게 다시 그려야 합니다.
            onart::VkPlayer::hwnd=app->window;
            onart::VkPlayer::width = ANativeWindow_getWidth(app->window);
            onart::VkPlayer::height = ANativeWindow_getHeight(app->window);
            if(isFirst){
                isFirst = false;
                onart::VkPlayer::start();
            }
            else{
                onart::VkPlayer::recreateSwapchainAndRedraw();
            }
            LOG_WITH("redraw needed");
            break;
        default:
            __android_log_print(ANDROID_LOG_DEBUG, "window", "%s:%d %s %d\n", __FILE__, __LINE__, __func__, cmd);
            break;
    }
}

 

void android_main(struct android_app* app) {
    app->onAppCmd=handleCmd;
    app->textInputState=0;
    while(true){
        int events;
        android_poll_source* source;
        while(ALooper_pollAll(0,nullptr,&events,(void**)&source) >= 0){
            if(source!=nullptr){
                source->process(source->app, source);
            }
            if(app->destroyRequested){
                break;
            }
        }
        if(app->destroyRequested){
            break;
        }
        onInput(app);
        if(onart::VkPlayer::hwnd) onart::VkPlayer::draw();
    }
}

문제는 화면을 돌리면 activity가 파괴되고 그 경우 android_main 자체가 루프를 탈출한 뒤 다시 시작한다는 점입니다. 맥락을 유지하고 스왑체인을 다시 만드는 건 사실 별 문제가 없는데요, 가장 큰 문제는 GPU 컨텍스트를 진정 끝낼 때(=VkPlayer::exit 호출 시점)가 언제인가? 하는 점입니다.

 

강제 종료 시(ex: 좌측 하단 메뉴 -> 모두 닫기) 전역 객체들의 소멸자가 정상적으로 호출되느냐? 안 됩니다. 이렇게 테스트해 봤어요. save state는 뭐.. 소멸자 호출이 안 되게 생겼으니 논외로 합시다.

 

#define LOG_WITH(x) __android_log_print(ANDROID_LOG_DEBUG, "window", "%s:%d %s %s\n", __FILE__, __LINE__, __func__, x)
struct dummy{
    dummy(){
        LOG_WITH("start");
    }
    ~dummy(){
        LOG_WITH("end");
    }
};

dummy d; // 전역. 처음에 start는 출력되고 강제 종료 시 end는 안 됨.

 

방법. 정상적으로 종료 버튼을 안 누른 니 잘못

게임은 어차피 C++ 코드로 구현될 것을 상정한다 이 말입니다. 종료 함수를 네이티브 코드에서 호출해야 합니다.  정상적으로 종료하는 방법은 GameActivity_finish를 호출한 후, ALooper 폴링을 한 뒤 android_main에서 리턴하는 게 있습니다. 이 finish를 호출하는 코드에서 VkPlayer::exit을 함께 호출하면 되겠죠. (exit에서 그 finish를 호출하거나) 그 외의 방법으로 종료하면 사용자가 스마트 관리자를 불러 알아서 메모리를 청소해야 할 거고요. 뭐 별 문제가 있겠습니까. 대부분의 모바일 게임은 가로 혹은 세로로 고정되거나 모드 전환을 게임이 한정시킬 텐데요. 180도 회전은 destroy가 호출되지도 않고요.

 

프로젝트 전체는 여기 있습니다.

AVK.zip
0.85MB

 

2.3. 프레임 페이싱

개발자 문서에 따르면 프레임 속도가 60과 30이 오가는 상황이 나올 때도 있다고 합니다. 조금 늦었다고 60과 30이 오간다면 실질적 플레이어 경험은 30FPS에 가까운데 전력은 60FPS에 버금가게 먹는 안타까운 상황이 나올 수 있습니다. 아무튼 프레임 페이싱에서 그 기준을 명시할 수 있을 테니, 해당 라이브러리를 사용해 봅시다.

 

라이브러리의 이름은 Swappy이며 구성을 통해 제트팩?으로 받은 AGDK에 속해 있습니다. 앞에서 CMakeLists에 넣어 놓았던 games-frame-pacing::swappy_static이 바로 그것입니다. 다음을 포함하면 관련 기능을 쓸 수 있습니다.

#include <swappy/swappyVk.h>
#include <swappy/swappy_common.h>

 

먼저 SwappyVk_determineDeviceExtensions를 이용하여 필요한 확장 이름들을 추가로 넘겨야 합니다. 여기서 요구하는 것은 확장 이름은 VK_GOOGLE로 시작하는 게 대표적일 겁니다. 단 GPU가 2개 이상 장착된 스마트폰은 굳이 고려할 필요가 없을 것 같아서, 스왑체인 확장을 제공하는 물리 장치를 일단 선택한 다음 이 함수를 가지고 vkDeviceCreateInfo.ppEnabledExtensionNames, vkDeviceCreateInfo.enabledExtensionCount에 넣기만 하면 사실상 된다고 봐도 될 것 같아요. 참고로 제 기기 기준으로 Swappy가 요구하는 확장은 VK_GOOGLE_display_timing 하나 나왔습니다.

 

        // VkPlayer::createDevice()
        // 물리 장치 가용 확장 확인
        uint32_t count;
        vkEnumerateDeviceExtensionProperties(physicalDevice.card, nullptr, &count, nullptr);
        std::vector<VkExtensionProperties> exts(count);
        vkEnumerateDeviceExtensionProperties(physicalDevice.card, nullptr, &count, exts.data());
        
        // Swappy 필요 확장 확인
        uint32_t swappyCount;
        SwappyVk_determineDeviceExtensions(physicalDevice.card, count, exts.data(),&swappyCount,nullptr);

        struct Name{
            char name[VK_MAX_EXTENSION_NAME_SIZE];
            explicit operator char*(){return name;}
        };
        std::vector<char*> swappyReqs(swappyCount);
        swappyReqs.reserve(swappyCount + DEVICE_EXT_COUNT);
        std::vector<Name> buffer(swappyCount);
        for(auto i=0;i<swappyCount;i++){
            swappyReqs[i] = (char*)buffer[i];
        }
        SwappyVk_determineDeviceExtensions(physicalDevice.card, count, exts.data(),&swappyCount,swappyReqs.data());
        for(const char* i : DEVICE_EXT){
            swappyReqs.push_back(const_cast<char*>(i));
        }


        // 장치 생성
        VkDeviceCreateInfo info{};
        info.sType = VK_STRUCTURE_TYPE_DEVICE_CREATE_INFO;
        info.pQueueCreateInfos = qInfo;
        info.queueCreateInfoCount = (physicalDevice.graphicsFamily == physicalDevice.presentFamily) ? 1 : 2;
        info.pEnabledFeatures = &features;
        info.ppEnabledExtensionNames = swappyReqs.data();
        info.enabledExtensionCount = swappyReqs.size();

 

그 다음 SwappyVk_setQueueFamilyIndex로 제출에 쓸 큐 계열 인덱스를 전달합니다.

 

        bool result = vkCreateDevice(physicalDevice.card, &info, nullptr, &device) == VK_SUCCESS;
        if (result) {
            vkGetDeviceQueue(device, physicalDevice.graphicsFamily, 0, &graphicsQueue);
            vkGetDeviceQueue(device, physicalDevice.presentFamily, 0, &presentQueue);
            SwappyVk_setQueueFamilyIndex(device, presentQueue, physicalDevice.presentFamily);
        }

 

마지막으로 화면 새로고침 주기를 확인해야 합니다. PC 환경에선 GLFW가 해 줬었고, 여기서 이걸 알려면 JNI 환경과 activity 객체를 넘겨야 합니다. 일단 다음이 public으로 선언되어 있는 것으로 가정합니다.

static JNIEnv* env;
static jobject jactivity;

 

이것들을 얻을 수 있는 곳은 여기겠죠?

// 나머지와 마찬가지로 APP_CMD_WINDOW_REDRAW_NEEDED에서 세팅하겠습니다.
onart::VkPlayer::env = app->activity->env;
onart::VkPlayer::jactivity = app->activity->javaGameActivity;

 

편의상 스왑체인 생성 직후 이를 확인하겠습니다.

if (vkCreateSwapchainKHR(device, &info, nullptr, &swapchain) != VK_SUCCESS) {
    __android_log_print(ANDROID_LOG_DEBUG, "VKPLAYER", "\n Failed to create swapchain \n");
   return false;
}
swapchainExtent = info.imageExtent;
uint64_t refreshDuration;
constexpr uint64_t NANO_INV = 1000*1000*1000;
SwappyVk_initAndGetRefreshCycleDuration(env,jactivity,physicalDevice.card,device,swapchain,&refreshDuration);

 

그런데 이렇게 하면 오류가 발생합니다. JNI 콜을 이 스레드에서 할 수 없다는 말을 하면서요. 별 수 없이 이렇게 해야겠군요.

 case NativeAppGlueAppCmd::APP_CMD_WINDOW_REDRAW_NEEDED:
    // ANativeWindow를 깔끔하게 다시 그려야 합니다.
    onart::VkPlayer::hwnd=app->window;
    onart::VkPlayer::width = ANativeWindow_getWidth(app->window);
    onart::VkPlayer::height = ANativeWindow_getHeight(app->window);
    onart::VkPlayer::jactivity = app->activity->javaGameActivity;
    if(isFirst){
        isFirst = false;
        app->activity->vm->AttachCurrentThread(&onart::VkPlayer::env,nullptr);
        onart::VkPlayer::start();
        app->activity->vm->DetachCurrentThread();
    }
    else{
        app->activity->vm->AttachCurrentThread(&onart::VkPlayer::env,nullptr);
        onart::VkPlayer::recreateSwapchainAndRedraw();
        app->activity->vm->DetachCurrentThread();
    }
    LOG_WITH("redraw needed");
    break;

 

이제 제대로 나옵니다. 저는 예상대로 16,666,666이라는 결과를 받았고, 이제 프레임 속도 조절을 세팅할 준비가 됐습니다. 스왑체인 생성의 바로 뒤에 이렇게 붙여줍시다.

 

SwappyVk_setWindow(device, swapchain, hwnd);
switch (NANO_INV / refreshDuration) {
    case 29:
    case 30:
        SwappyVk_setSwapIntervalNS(device, swapchain, SWAPPY_SWAP_30FPS);
        break;
    case 59:
    case 60:
        SwappyVk_setSwapIntervalNS(device, swapchain, SWAPPY_SWAP_60FPS);
        break;
    case 89:
    case 90:
        SwappyVk_setSwapIntervalNS(device, swapchain, refreshDuration);
        break;
    default:
        break;
}

 

이제 표시 큐에 제출하는 부분을 맡겨벼리면 됩니다.

// vkQueuePresentKHR(presentQueue, &presentInfo) 부분이 저걸로 바뀜
if (SwappyVk_queuePresent(presentQueue, &presentInfo) != VK_SUCCESS) {
    return;
}

 

마지막으로 스왑체인을 해제할 때 이걸 먼저 호출합니다.

void VkPlayer::destroySwapchain() {
    SwappyVk_destroySwapchain(device, swapchain);
    vkDestroySwapchainKHR(device, swapchain, nullptr);
}

 

이론상으로는 잘 될 거라는데 저는 어째 더 느려졌네요.. CPU GPU 사용량은 비슷하게 낮은데요.

 waitIdle 호출을 없애니 60이 찍히는 것도 그렇고(단, 단순히 wait만 없애면 메모리 안정성이 크게 떨어짐) 그래픽스 큐와 제출 큐 계열이 동일한 걸로 보아, 세마포어 동기화로 바꾸면 60이 어떻게든 되겠네요.

그렇대도 오히려 느려지는 건 구글에 문의라도 해 봐야겠는데요. (waitIdle 호출을 없애고 세마포어 동기화로 바꿔도 Swappy를 사용하여 제출하면 40 전후 밖에 안 나옴) 왜 이래 이거?

 

세마포어 사용법은 별도로 설명은 안 하겠지만 그것까지 적용된 전체 프로젝트는 여기 있습니다. 참고로 세마포어를 더 추가하고부터 GpuWatch를 켜면 간헐적으로 로그에 E/vulkan: dequeueBuffer failed: Function not implemented (-38) 가 뜨고 그때부터 안 그려지네요.

AVK.zip
0.89MB

 

일단 저는 구글에 제대로 문의하기 전까진 스와피 프레임페이싱은 안 쓰는 걸로 하겠습니다.

 

3. 리소스

PC를 대상으로 하는 경우 파일 시스템 활용이 자유롭기 때문에(백신에 걸릴 만한 일을 대놓고 하지나 않는다면야..) 리소스의 사용 방식이 꽤 자유롭습니다. 압축이나 암호화를 하여 런타임에 불러와서 풀거나,  그냥 불러올 수도 있고, (잘 그러진 않는 모양이지만)바이너리의 텍스트/데이터 영역에 넣을 수도 있죠. 그런데 1, 2번째 선택지는 모바일 대상인 경우 zip 파일 같은 걸 직접 뿌린다기보다는 앱 패키지를 깔 때 어떻게든 같이 들어가겠죠. 그걸 C++ 단에서 불러오는 법을 잘 알아야 PC와 안드로이드 플랫폼에서 파일 등 리소스 사용 방법을 통일하여 제공할 수 있을 겁니다.

 

3.1. 이미지와 모델 등 직접 처리하는 파일

단순히 파일을 읽고 쓰는 방법만 안다면 충분하겠죠.

 

3.1.1. 파일 쓰기

GameActivity가 있는 프로젝트에서는 일단 이렇게 작성해서,

#define LOG_WITH(x) __android_log_print(ANDROID_LOG_DEBUG, "window", "%s:%d %s %s\n", __FILE__, __LINE__, __func__, x)
void android_main(struct android_app* app) {
    std::string path = app->activity->internalDataPath; // 앱에서 자유롭게 사용할 수 있는 서브디렉토리
    path += "/example.txt";
    LOG_WITH(path.c_str());
    FILE* fp = fopen(path.c_str(),"wb");
    if(fp){
        fwrite("abcd",1,4,fp);
        fclose(fp);
        LOG_WITH("fopen successed");
        fp = fopen(path.c_str(), "rb");
        char buf[5]={};
        fread(buf,1,4,fp);
        LOG_WITH(buf);
    }
    else{
        LOG_WITH("fopen failed");
    }

}

대충 다음 정도의 출력이 나오면 성공입니다.

(현재 소스 파일 이름:행 번호) /data/user/0/onart.pack.avk/files/example.txt
(현재 소스 파일 이름:줄 수) fopen successed
(현재 소스 파일 이름:줄 수) android_main abcd

 

물론 internalpath 아래라면 fcntl.h, unistd.h의 fd 동작을 써도 충분히 가능할 거긴 합니다만 굳이 그런 길을 선택하는 건 특별한 이유가 없으면 안 하겠죠?

 

3.1.2. 파일 읽기

사실 여기서 쓴 파일과 리소스 파일은 근본적으로 차이가 있습니다. 스튜디오로 앱을 패키징하고 구글 플레이를 통해 프로그램을 배포한 경우에, 저기로 넣을 파일을 미리 선택할 수 있을까요? 혹은 리소스로 넣은 파일이 저기로 들어가 줄까요? 최소한 문서를 보면 게임의 리소스는 논리적으로는 '앱별 저장소'에 들어가는 게 맞지만, 용량은 한정적이며 많은 데이터를 저장하려면 다른 유형을 사용해야 한다고 나와 있습니다. 한정적이라는 모호한 표현이 있긴 하지만 게임 리소스라면 일반적으로 많은 데이터라고 불리기 충분하지 않겠어요?

 

그러니 일단 3.1.1의 내용은 유저 세팅이나 세이브 파일 등을 위해서만 남긴다고 치고, 리소스로써 넣은 것들을 어떻게 접근하는지를 파악하는 게 앞으로의 일을 더 쉽게 만드는 태도가 되겠죠. 이렇게 해 줍시다.

 

누르면 폴더 위치를 변경하는 옵션도 나오는데, 여느 때처럼 별 이유가 없다면 그냥 쓰는 게 나을 것 같네요. 저는 크로스플랫폼 엔진을 목표로 하는 만큼 나중에 안드로이드 스튜디오 프로젝트 폴더에 종속되지 않거나 그래 보이는 폴더로 바꿀 수도 있겠네요. 아무튼 지금 만든 폴더에 적당한 텍스트 파일을 넣어 보고, 이런 식으로 읽어 봅시다.

    AAsset* asset = AAssetManager_open(app->activity->assetManager,"example.txt",AASSET_MODE_STREAMING);
    if(asset){
        char aa[7]={};
        auto x = AAsset_getLength(asset);
        AAsset_read(asset, aa, 6);
        LOG_WITH(aa);
        AAsset_close(asset);
    }
    else{
        LOG_WITH("asset read failed");
    }

 

(저 같은 경우 example.txt에는 Vulkan이라는 6바이트가 들어 있습니다) 꽤나 직관적이죠. 참고로 읽기 전용이라고 합니다.

모드는 AASSET_MODE_BUFFER랑 AASSET_MODE_RANDOM도 있습니다. STREAMING은 보통 파일을 열었을 때처럼 순차적으로 읽을 수 있는 방식, BUFFER는 전체를 한 번에 올리려는 방식, RANDOM은 그 이름처럼 앞/뒤로 탐색을 할 수 있습니다. 다른 게 꼭 필요한 경우가 아니면 STREAMING이 가장 좋을 거라 봅니다.

 

이 문서에 따르면 Gradle 빌드 세팅을 조정해야 한다고 하는데, 위의 Assets folder를 추가하면서 그런 세팅이 자동으로 추가되진 않았지만 정상적으로 위의 함수를 사용할 수는 있었습니다. 아마 스튜디오라서 그냥 된 걸 수도 있겠네요.

 

*텍스처 압축 같은 경우 해당 문서와 무관하게, 얼마 후에 정규 13편에서 다루어질, KTX(github)를 통해 사용할 겁니다.

 

3.2. 소리 파일, 오보에

이미지 파일과 모델 파일은 사실상 한 번에 전체를 사용하며, 따라서 사용 중에는 전체가 GPU 메모리에 상주해야 합니다. 하지만 음원을 해독한 PCM 데이터 전체가 메모리에 상주한다면 프로세스가 엄청나게 무거워집니다. (초당 44100샘플인 스테레오 음성을 uint16_t PCM으로 메모리에 올린다고 치면, 초당 176400바이트, 대략 127KB를 씁니다. 1분짜리면 10MB죠.) 즉 이미지처럼 압축해제하고 끝이 아니라 복호화 속도와 메모리 절약의 절충점을 잘 찾는 게 중요해요.

 

오보에는 구글이 안드로이드에서 쓰라고 만든 음원 재생 모듈로 Apache2.0 라이센스를 채택하였습니다. AGDK 안에 있으며 안드로이드 4.1 (API L16)부터 지원되긴 하는데, 어차피 타겟 엔진을 위한 Vulkan 자체가 7.0 (API L24)부터 가능하니 팍팍 쓰면 되겠죠.

 

기존에 libvorbis/FFmpeg(LGPL 빌드) + PortAudio를 이용하여 음원 재생 라이브러리를 만들어 두긴 했지만 (리눅스 계열이긴 하지만) 안드로이드에서 확실히 돌아갈지도 모르겠고 링킹라이브러리 따로 준비하는 게 안드로이드 기준으로는 하기 귀찮고 구글이 만든 쪽의 성능과 안정성이 더 좋을 테니 확실히 알아보도록 해요...라고 생각했는데, 얘는 압축 음원을 주면 바로 재생해 주는 IrrKlang 같은 라이브러리가 아니라 PortAudio랑 똑같이 PCM을 가지고 콜백으로 전달해서 재생하는 라이브러리였네요. 근데 지금 적절한 디코더를 찾고 링버퍼를 만들고 데이터를 올리고 합치는 걸 여기서 제대로 얘기하기엔 배보다 배꼽이 더 큰 격이 되겠죠. 그러니 아쉽지만, 지금은 그런 건 바로 위의 '라이브러리'라고 되어 있는 링크를 타고 가서 제가 쓴 소스를 구경하거나 다른 통합된 오픈소스(*상기한 IrrKlang은 상용 라이센스로, 비영리 사용만 무료임)를 찾아보시고요, 여기서는 소박하게 사인파나 틀어 봅시다. (싫으면 JNI로 단순 음원 재생 부분을 맡겨 보세요.)

 

지금까지 build.gradle에 있던 것에 더하여 새로 들어갈 부분은 아래 2개입니다.

android {
    defaultConfig {
        externalNativeBuild {
            cmake {
                arguments "-DANDROID_STL=c++_shared"
            }
        }
    }
}
...
dependencies {
    implementation 'com.google.oboe:oboe:1.5.0'
}

이후 CMakeLists.txt에 다음을 추가합니다.

...
find_package (oboe REQUIRED CONFIG)
...
target_link_libraries(
...
oboe::oboe
...
)

 

이제 쓸 준비가 됐습니다. 다음 헤더가 필요합니다.

#include <oboe/Oboe.h>

 

그리고 기본적으로 재생할 스트림의 옵션을 만들어줍니다. 대략적인 설명은 할 수 있는데, 여기 부분은 레퍼런스를 확인하길 권장합니다. 이런 라이브러리의 핵심은 PCM 요청 콜백 함수이기 때문에 이런 라이브러리가 익숙치 않다면 거길 먼저 아는 게 좋을 것 같아요.

oboe::AudioStreamBuilder builder;

builder.setDirection(oboe::Direction::Output); // "나오는 소리"
builder.setPerformanceMode(oboe::PerformanceMode::LowLatency); // 전력 소모량, 지연도, 글리치 안정성 등
builder.setSampleRate(44100); // 샘플 주파수
builder.setSharingMode(oboe::SharingMode::Exclusive); // 원하는 음성 장치 공유 모드
builder.setFormat(oboe::AudioFormat::Float); // PCM 샘플 포맷
builder.setChannelCount(oboe::ChannelCount::Mono); // 채널 수

 

그 다음 PCM을 요청하는 콜백함수를 등록하는데요, 이는 클래스를 상속하여 사용합니다. PortAudio는 C API로 함수포인터를 등록하던데..

class MyCallback : public oboe::AudioStreamDataCallback {
public:
    oboe::DataCallbackResult
    onAudioReady(oboe::AudioStream *audioStream, void *audioData, int32_t numFrames) {
        static float phase = 0.0f;
        
        float *outputData = static_cast<float *>(audioData);

        const float amplitude = 0.5f;
        for (int i = 0; i < numFrames; ++i){
            phase += 0.1f;
            outputData[i] = std::sin(phase) * amplitude;
        }
    
        return oboe::DataCallbackResult::Continue;
    }
};

 

(오디오 출력 시의) 콜백함수를 짤막하게 설명하자면, 스트림을 오픈하고 나서 계속 호출되며 (자세히는 안 알아보긴 했지만 Portaudio가 그랬듯 인터럽트일 것 같아요. 때문에 메모리 동적할당, 파일 입출력, 뮤텍스 등 조금이라도 오래 걸릴 소지가 있는 건 절대 하면 안 됩니다.) 우리가 여기 전달해야 할 값은 PCM입니다. 전달한 PCM은 거의 바로 재생됩니다. 위의 onAudioReady 함수에서 audioData 자리에 PCM 데이터를 쓰면 되고, 값의 형식은 -1~1의 float나 전 범위의 signed short int 값 정도가 있다고 생각하면 될 겁니다. builder.setFormat으로 전달한 것에 맞추시면 됩니다. 채널 수가 2 이상인 경우라면 각 샘플을 교차로 배치하시면 됩니다. (ex: 2채널(스테레오)에 1 2 3 4를 전달하면 각 채널은 1 3과 2 4를 재생) 리턴값은 어지간하면 저 값으로 고정입니다. (Stop을 리턴하면 스트림이 종료됩니다.)

 

이걸 위의 builder에 등록하고(객체의 형태여야 합니다), 열어 줍니다. 여기는 하드웨어와 실제 연결을 시도하는 부분이라 보면 되겠네요.

MyCallback callbackObj;
builder.setDataCallback(&callbackObj);

std::shared_ptr<oboe::AudioStream> mStream;
oboe::Result result = builder.openStream(mStream);

 

아래와 같이 실제 가동이 가능합니다.

mStream->requestStart(); // 이걸 호출하면 콜백 함수가 계속 호출되기 시작, 그에 따라 소리가 나옴
mStream->requestStop(); // 이걸 호출하면 콜백 함수가 중지되며 소리도 중지

 

혹시 코드가 파편화되어 보여서 전체를 보고 싶을 수 있으니, 프로젝트 폴더를 다시 올려드립니다. (avk.cpp 부분만 바뀌었습니다.)

 

AVK.zip
1.38MB

사인파가 잘 되었다면 오카리나 같은 소리가 날 겁니다. 소리가 약간 불안정한 면이 있긴 한데 sin을 너무 많이 불러서 느려서 그런 듯 합니다. 이렇게 직각삼각형 모양 PCM으로 만들면 꽤 안정적으로 납니다.

for (int i = 0; i < numFrames; ++i){
            phase += 0.03f;
            if(phase >= 1.0f) phase = -1.0f;
            outputData[i] = phase * amplitude;
        }

 

2개의 콜백을 비교해 보셨고 폰 성능이 제 것보다 훨씬 좋은 게 아니라면 이 콜백이란 녀석이 참 민감하다는 것을 인식하셨을 겁니다. 동적할당, 입출력 등 오래 걸리는 건 안 되는데 그럼 어떻게 제대로 전달하느냐? 해서 가능한 방법 중 하나가 링버퍼입니다. 이는 상기했듯 그리 만만한 주제도 아니고 애초에 중심 내용도 아니라서 바로 다룰 생각은 없고요, 간단히 힌트만 적어 두겠습니다. 간만에 (특별편이라 나오지 않고 있던) QnA 조교를 써 보죠. 직접적인 구현의 예시는 위의 링크의 소스나 다른 통합 음원재생 오픈소스를 찾아서 읽어 보세요.

 

나는 오보에(포트오디오)입니다.
나는 링 버퍼입니다.
OOOOOOOOOO
(Callback) 2개 가져간다.
XXOOOOOOOO
음원에서 복호화, 믹싱을 시작해야겠군.
(Callback) 3개 가져간다.
XXXXXOOOOO
복호화가 완료되었군. OOXXXOOOOO
[2]부터 [4]까지 채워야겠네.
(Callback) 3개.
OOXXXXXXOO
복호화가 완료되었군. OOOOOXXXOO
[5]부터 [7]까지 채워야겠네.

 

(맨 뒤까지 요구가 끝나면 당연히 맨 앞부터 다시 시작합니다.)

 

구체적인 구현이나 스레드 분배 등은 여러분 몫입니다. 이 방법에 관하여 한 가지 말씀드리자면, 창 이벤트/게임 로직/렌더링을 포함한 모든 걸 같은 스레드에서 하면 명확한 한계가 생기기 때문에(ex: 창을 잡고 이벤트를 안 놔 주고 있으면 인터럽트 콜백은 계속 되지만 음원 복호화가 끊김) 어지간하면 스레드를 별도로 쓰세요.

 

실제로 제가 돌린 데(9세대 i3)서는 음원 2~3개 정도 상주해도 콜백이 요구하는 것보다 복호화+믹싱이 훨씬 빨랐습니다. 후자가 느려도 음질 변동 없이 그대로 유지될 수 있는 방법은 최소한 제가 아는 것 중엔 없고요(따라잡힐 때마다 한 바퀴 정도 양보하는 방법이 음질엔 문제가 생겨도 무난할 듯), 따라잡히기 않기 위해 믹싱에 더 많은 스레드를 주거나 메모리를 더 희생해서 복호화 시간을 아끼는 것도 방법이겠죠.

 

 

맺음

지금까지 안드로이드에서 보통의 기능을 가진 (Vulkan) 그래픽스 응용을 만들기 위해 필요한 것들을 2개 글에 걸쳐 알아보았습니다. 스마트폰 OS는 입력 하드웨어만 다른 게 아니라, PC 것보다는 까다로운 프로그램/보안 정책을 가지고 있기 때문에 문서에서 말하는 대로 따라가서 PC랑 비슷한 정도의 메모리를 맞춰 놓는 과정만 거치면 그 뒤로는 무난히 개발할 수 있을 겁니다. Java 쪽 코드를 최소화하고 C++ 코드에서 거의 다 해결할 수 있게 하여 PC 대상 프로그램과 비슷하게 프로그램을 맞출 수 있는 GameActivity(AGDK)가 있으니 잘 활용하면 좋겠네요.

'Vulkan > 특별편' 카테고리의 다른 글

Vulkan 물리 장치 속성  (0) 2022.09.25
Vulkan instance와 device 확장들  (0) 2022.09.18
안드로이드 스튜디오 NDK와 Vulkan - 1  (0) 2022.08.21