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

2022. 8. 21. 12:32Vulkan/특별편

개요

저는 본업이 생기면서 새로운 Vulkan 기반 렌더링 프레임워크 설계 구상에 많은 차질이 생겼고(여름이라 더워서 스스로에게 핑계를 대 봅니다.) 뭔가 새로운 자극이 필요했습니다. 여러 가지 선택이 있지만 그 중 안드로이드 플랫폼으로의 Vulkan 연결을 먼저 하는 걸 선택했습니다. 기본적으로는 다수의 자료가 여기를 가리키는데요, 그 내용만 보면 기존의 소스를 기반으로 수정하는 것만 가능한 게 아닌가 생각도 듭니다. 아무래도 그것보단 많이 알아야겠죠. 따라서 그보다 앞인, 안드로이드 스튜디오에서의 NDK 사용 방법부터 시작하여 창 표면 통합 등 다른 코드의 커버까지 들어가는 식으로 진행해 보겠습니다.

 

제목에서 알 수 있듯 글 하나로 끝내지 않았습니다. 이 글에서 커버하는 범위는 딱 Android 스튜디오에서 Vulkan 프로그램을 만들어 삼각형 하나를 그려 보는 것까지입니다. (선수 과목은 Vulkan 기본, 그리고 Android 스튜디오와 NDK 설치입니다.)

 

목차

1. 무엇이 달라지는 것인가?

2. Android NDK 시작하기

  2.1. JNI

  2.2. 링킹

3. Vulkan 프로그램을 만들어 안드로이드에서 실행해 보기

  3.1. 링킹 라이브러리

  3.2. Vulkan-Android WSI

 

1. 무엇이 달라지는 것인가?

PC와 모바일 환경은 개발이든 응용 사용이든 아주 다양한 면에서 다를 겁니다. 이것을 적고 있는 저는 아직 뭐가 다를지 모르는 부분도 많지만, 최소한 큰 그림 정도만 확인하고 가면 좋을 것 같습니다. 생각보다 자료가 없어서 이번 글에 이와 관련된 모든 내용이 구현되지는 않습니다.

 

  • SDK 특성: 안드로이드에서 작동하는 응용을 돌릴 때 꼭 안드로이드 스튜디오 + Java/Kotlin만 사용할 수 있는 것은 아니지만, 일반인 수준에서 응용을 만들기 위해 시도하기 가장 무난한 방식은 안드로이드 스튜디오 상에서, 저수준 처리 부분만 네이티브 개발 키트를 이용하여 C++로 하고 응용 상의 흐름은 Java로 하는 것이란 생각이 듭니다. 
  • 창 시스템: PC 플랫폼을 위해서는, GLFW 등을 비롯한 거의 제한 없는 라이센스의 오픈 소스 크로스 플랫폼 라이브러리가 굉장히 많습니다. 그래서 창 표면과 스왑체인을 통합하기 위해 우리가 작성할 코드는 한 종류면 충분했습니다. 하지만 최소한 GLFW는 현재 모바일을 지원하지 않아 별도의 통합 코드가 필요합니다.
  • 장치 특성: 여기에 장치 특성에 관련된 몇 가지 가이드라인이 알려져 있습니다. Vulkan을 통해 하드웨어 가속을 직접 이용할 수 없는 경우가 있다는 점, 화면 180도 회전에 대한 사전 대응의 필요성 등이 있습니다. 진지하게 그 플랫폼에 대한 저수준 개발을 고려한다면 저 문서를 비롯한 여러 특성에 잘 주목해야겠죠.

 

2. Android NDK 시작하기

이 부분의 본문은 여기를 그대로 따라갑니다.

NDK, 즉 네이티브 개발 키트는 안드로이드에서 C, C++ 코드를 JNI라는 프레임워크를 통해 자바 코드에서 호출하게 하여 개발할 수 있게 하는 도구입니다. 문서에는 네이티브 프로젝트 만들기, 그리고 가져오기의 방법이 다르다고 설명되어 있습니다. 여기서는 목적상, 만들기 부분만을 다루겠습니다.

 

위와 같이 새 프로젝트 생성을 누르고, 스크롤을 내리면 나오는 Native C++를 선택합니다.

필요한 설정을 완료합니다. 최소 SDK는 현재 최종 목적이 Vulkan인 만큼 최소 7.0 이후를 선택합니다. 목적이 그게 아니라면야 Kitkat(Api 19) 정도로 설정해도 큰 상관은 없겠습니다만, 아마 그쯤 되면 한국어로 된 글들 중에서도 더 도움이 잘 되는 게 많을 겁니다. 벌칸을 위해서라면 이게 가장 좋냐고요? 저는 아주 특별한 이유가 없으면 공식 문서만 보기 때문에 잘 모르겠지만, 아마 아니지 않을까 생각만 합니다.

 

 

그 다음 필요한 경우 이것저것 받아오는 과정을 거치는데, 끝나면 Next를 눌러 줍니다. 그 다음 C++ 지원의 기준을 선택하고 Finish를 클릭합니다. (아주 많은 경우에 선택지 중 최신 버전이 가장 좋습니다.)

 

 

프로젝트 생성이 끝났다면, 개발자 모드가 활성화된 여러분의 스마트폰을 USB 선으로 연결하거나 안드로이드 스튜디오에서 제공하는 가상 장치를 이용하여 있는 그대로를 테스트해 봅시다. 우측 상단을 보면 나오는 위의 삼각형 버튼을 누르면 됩니다. 코드는 가운데의 텍스트뷰에 Hello from C++라는 글귀가 뜨면 성공입니다.

 

제대로 되었다면 문서에서 다음으로 넘어가기 전에 아주 잠깐만 자유롭게 둘러보겠습니다. 창 왼쪽을 차지한 Project 부분에서 실제 파일시스템 말고 Android 뷰를 보면, java와 cpp로 나뉘어 있는데 그 중 cpp/includes를 펼치면 LLVM이란 이름이 보입니다. 이를 펼쳐 보면 대충 이런 게 펼쳐집니다.

 

 

GLES들, vulkan, arm64intr.h, immintrin.h 등에 눈이 가는데, LLVM의 이런 지원을 어느 정도 활용할 수 있겠군요. 운영체제에 종속되는 POSIX 관련이나 Win32 관련 등을 제외하면 제가 아는 한 Clang, GCC, MSVC에서는 대부분의 경우 같은 헤더와 코드를 사용할 수 있습니다. 저는 ARM과 x86 아키텍처에 대한 SIMD 확장에 관심을 가장 먼저 가졌는데, intrin.h는 둘 모두에 대한 SIMD 관련 intrinsic 헤더를 포함시켜 주지만 사실 이게 헤더 포함만 자동이고 딱히 통일된 이름과 매개변수(특정 타입의 배열을 받는..)의 함수 제공은 없습니다. 그래서 x86에서 128비트짜리 계산을 한 번에 하는 SSE2(정확히는 SSE4 계열까지는 128비트 범위에서 확장), ARM에서 128비트짜리 계산을 한 번에 하는 Neon의 경우 나중에 따로 간결히 다뤄 보겠습니다. (x86 intrinsic, ARM intrinsic 문서)

 

2.1. JNI

가장 먼저 응용 단의 진입점이라 할 수 있을 MainActivity.java를 확인합니다.

package onart.pack.ndkstart;

import androidx.appcompat.app.AppCompatActivity;

import android.os.Bundle;
import android.widget.TextView;

import onart.pack.ndkstart.databinding.ActivityMainBinding;

public class MainActivity extends AppCompatActivity {

    static {
    /*  굳이 설명을 보지 않아도 공유(동적) 라이브러리를 불러온다는 것을 추측할 수 있습니다.
        자세한 설명이 필요하면 여기를 확인하세요.
        https://docs.oracle.com/javase/10/docs/api/java/lang/System.html#loadLibrary(java.lang.String)
        */
        System.loadLibrary("ndkstart");
    }

    private ActivityMainBinding binding;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        binding = ActivityMainBinding.inflate(getLayoutInflater());
        setContentView(binding.getRoot());

        TextView tv = binding.sampleText;
        tv.setText(stringFromJNI()); // 바로 아래에 있는 함수를 확인해 봅시다.
    }

    /**
     * 이 응용과 함께 포장된 라이브러리에서 구현한 네이티브 함수입니다.
     * 그쪽 코드로 넘어가 보겠습니다.
     */
    public native String stringFromJNI();
}

static 블록은 클래스에 대한 코드가 로드될 때 바로 실행됩니다. C++ 코드(아마 기본 native-lib.cpp로 되어 있을 겁니다.)에서 미리 만들어져 있는 함수 이름을 바꾸면, JAVA 측의 코드에서 다음과 같은 오류를 확인할 수 있습니다. 아래 사진과 같이, Java_(패키지 이름)_(java 소스파일 이름)_(C++ 함수 이름)이 native 함수 명명법인 것 같네요. 사실 여기서는 패키지 이름이 곧 소스 파일 디렉토리 구조이기 때문에, Java_(프로젝트 최상위 디렉토리 기준 상대 파일 경로)_(C++ 함수 이름)이 맞겠네요.

한편 C++ 코드도 확인해 보겠습니다.

 

#include <jni.h>
#include <string>

extern "C" JNIEXPORT jstring JNICALL
Java_onart_pack_ndkstart_MainActivity_stringFromJNI(
        JNIEnv* env,
        jobject /* this */) {
    std::string hello = "Hello from C++";
    return env->NewStringUTF(hello.c_str());
}

 

보니까 함수 쓰는 법은 위와 같은데,  jstring, JNIEnv가 무엇인지 알지 못하면 전혀 앞으로 나아갈 수 없지 싶습니다. 이것만 보면 jstring은 java 측에서 받아먹을 수 있는 문자열이고 JNIEnv는 C++ 객체와 Java 객체를 이어주는 역할 같죠? 그에 대한 설명은 바로 여기 있습니다. 참.. 들어가 보면 내용이 짧지 않아요. 곧바로 정리해 봅시다.

 

그 전에 구글 측 문서를 참고할 필요도 있습니다. 쉽게 쉽게 요약하자면 아래와 같습니다.

  • 바로 위의 코드와 같이 C++ 객체와 Java 객체를 변환하는 부분, 즉 JNI 레이어에서 오가는 리소스의 양과 자체 호출 빈도를 최대한 줄여야 합니다.
  • 응용 로직 계층(여기 기준으로는 Java)과 C++ 코드로 작성된 코드 간 비동기 통신을 가능한 피합니다. Java 측 스레드 하나를 더 만들어 그것을 통해 동기적으로 C++ 함수를 호출하라는 말 같네요. 이건 저의 목적과 아주 밀접한 안내인데, 크로노스나 사샤 등 더 잘 된 사례의 설계를 잘 확인하면서 만드는 게 좋겠습니다.
  • JNI에 직결된 다중 스레드를 최대한 피합니다. 각 작업 스레드 내의 객체가 아닌, 한 스레드 안의 대응 객체를 활용하라는 말 같습니다.
  • 인터페이스 코드를 최대한 간결히 컴팩트하게 유지합니다. 해당 안내에는 JNI 자동 생성 라이브러리를 활용할 수 있다고 되어 있는데, 그런 게 어디 있는지는 지금 모르겠네요.

 

2.1.1. JNI 프로그래밍

 

상기한 대로 System.loadLibrary 함수를 호출한 후 그 라이브러리의 네이티브 함수를 호출할 수 있습니다. JAVA 단에서 native로 선언한 함수가 다음과 같이 있습니다.

 

package pkg;  
class Cls { 
     native double f(int i, String s); 
     ... 
}

 

이것의 구현을, C단에서는 이렇게 합니다.

 

jdouble Java_pkg_Cls_f__ILjava_lang_String_2 (
     JNIEnv *env,        /* JNI 포인터 */
     jobject obj,        /* java 측 "this" 포인터 */
     jint i,             /* 인수 #1 */
     jstring s)          /* 인수 #2 */
{
     /* Java 문자열의 C 복사를 획득 */
     const char *str = (*env)->GetStringUTFChars(env, s, 0);
     // C++의 경우 멤버함수의 개념이 있어, env->GetStringUTFChars(s, 0);와 같은 사용이 가능.
     // 코드가 C++로 컴파일되는 경우 앞에 extern "C"가 필요함

     /* 임의의 처리 */
     ...

     /* str 사용 끝 */
     (*env)->ReleaseStringUTFChars(env, s, str);

     return ...
}

 

 Java_pkg_Cls_f까지는 방금 나온 규칙대로인데, 뭔가 다른 게 있습니다. 네이티브 함수 이름의 규칙 중, 네이티브 측의 이름 마지막에는 __ 뒤에 맹글된 인수 시그니처를 작성해야 합니다. 이는 오버로드된 함수를 위한 것인데, 타입과 함수 이름의 관계는 커닝 페이퍼로 시작하도록 합시다.

 

시그니처 Java 타입
Z boolean
B byte
C char
S short
I int
J long
F float
D double
L클래스이름_2 클래스(_로 구분된 fully-qualified class name)
_3타입 타입[]

 

참고로 클래스 등의 이름에 _가 들어가는 경우, 그 대신 _1로 써야 합니다. 마찬가지로 _2는 ;라는 뜻으로 통용되며 _3은 [라는 뜻으로 통용됩니다. 함수 이름이 유니코드로 구성된 경우 각 자는 _0abcd와 같이 _0 뒤에 16진 번호를 4자리로 붙입니다.

그래서 Java_pkg_Cls_f__ILJava_lang_String_2를 보면, 

 Java_pkg_Cls_f__ILJava_lang_String_2: 지금껏 알던 내용

 Java_pkg_Cls_f__ILJava_lang_String_2: Java 정수

 Java_pkg_Cls_f__ILJava_lang_String_2: Java String 클래스(/ 대신 _를 사용)

이렇게 적용되었네요.

 

참고로 안드로이드 스튜디오를 사용하여 안드로이드 응용을 만드는 경우 Java(Kotlin) 단에서 native 함수를 선언하면 IDE 기능으로 C++ 함수도 사용할 수 있습니다.

 

타입들 앞에는 j가 붙어 있습니다. Java의 기초 타입인 boolean, byte, char, short, int, long, float, double은 단순히 그 앞에   j를 붙인 것에 대응하며 void는 그대로 void입니다. 덧붙여 jsize도 jint 대신에 사용할 수 있습니다. 다음도 참고하세요.

 

typedef union jvalue { 
    jboolean z; 
    jbyte    b; 
    jchar    c; 
    jshort   s; 
    jint     i; 
    jlong    j; 
    jfloat   f; 
    jdouble  d; 
    jobject  l; 
} jvalue; 

 

참조로 전달되는 매개변수(기준이야 물론 Java에서 참조 전달이냐 값 전달이냐의 차이입니다. 위의 기초 타입을 제외하면 모두 참조 전달로 보아도 됩니다.)의 경우 얘기가 조금 길군요. 우선 네이티브 코드로 전달된 객체는 VM 측에서 계속 참조를 유지하므로 자동 해제(GC)가 되지 않습니다. 그러니 네이티브 코드 측에서 더 이상 사용하지 않음을 명시합니다. 위의 ReleaseStringUTFChars도 그런 역할 같군요.

 

아무튼 Java 객체에 접근하는 방법은 다음과 같습니다.

 

객체의 함수는 다음과 같이 호출할 수 있습니다. Call???Method 함수에서 ??? 부분은 리턴 타입입니다. 보통의 Java 클래스 객체를 리턴받으려면 CallObjectMethod를 호출하면 될 겁니다.

jmethodID mid = env->GetMethodID(cls, “f”, “(ILjava/lang/String;)D”); 
jdouble result = env->CallDoubleMethod(obj, mid, 10, str);

 

 

필드 접근도 똑같습니다. 함수가 GetFieldID이며, 그 함수의 리턴 타입은 jfieldID입니다. 값을 얻는 것 역시 GetDoubleField와 같이 Get???Field로 하면 됩니다. 이는 참조자를 리턴하는 것이 아니므로, 값을 정하려면 Set???Field 함수를 이용해야 합니다.

 

정적 함수 및 변수들도 방법이 똑같습니다. GetStaticMethodID와 같이 Get/Set/Call 바로 뒤에 Static이 들어갑니다.

 

jobject형 객체를 가지고 그 클래스를 얻으려면 env->GetObjectClass() 함수를 호출하면 됩니다. Static 멤버를 가져오는 등의 작업은 역시 jclass형을 씁니다. env->FindClass(이름)으로 찾아도 되기는 합니다.

 

당연하게도 C/C++ 측에서 멤버변수와 함수에 접근하는 것은 오래 걸리는 일입니다. 그러한 부분을 최소화하는 것이 중요합니다.

 

함수의 매개변수인 문자열이 이해가 조금 어렵군.
첫 번째는 jclass형. 위에서 설명한 대로 얻으면 됩니다. 2번째는 함수 이름. 3번째는 함수 시그니처입니다. 함수 시그니처의 경우, (매개변수 시그니처)리턴값 시그니처라는 표현을 가집니다.
위의 코드를 예로 들면, 찾고자 하는 멤버 함수는 double f(int, java.lang.String)이라는 게 되겠죠.
필드 접근의 3번째로는 필드의 타입 시그니처만 적으면 되는 건가? 이를 테면 double인 필드면 "D"만 적는 걸로.
그렇겠지?

 

내용 중에서는 극히 일부만을 다루었습니다. 정확한 이해를 위해서 위 링크를 직접 확인할 것을 권장합니다.

 

2.2. 링킹

새로이 c 혹은 cpp 파일을 컴파일해서 그것들끼리 링킹하는 경우는 CMake를 조금만 알면 쉽게 할 수 있습니다. 그리고 공식 문서에서는, "CMake와 함께 미리 빌드된 라이브러리를 사용하는 방법은 CMake 매뉴얼에서 IMPORTED 타겟과 관련된 add_library 문서를 참조하세요."라고 하니 기존에 CMake를 쓰던 것과 동일하겠죠.

 

 가장 좋은 방법은 소스째로 하위의 CMake로 구성하는 것이겠지만, 오픈소스 코드를 거기에 그대로 넣는다 치면 리포지토리가 무거워지고 하니 다른 방법도 되긴 하나 확인해 봅시다. 저 CMakeLists.txt를 파일 탐색기에서 찾으면 app/src/main/cpp에 같이 있는데, 그냥 리눅스에서 돌아가게 만들기만 하면 잘 되는지 테스트를 해 보겠습니다. WSL에서 다음 프로그램을 이용하여 간단한 정적 라이브러리를 만들어 보겠습니다.

 

// a.cpp
const char* slibstr(){
    return "Hello from static";
}

 

 

clang++ -c a.cpp -target arm64; ar rcs liboastr.a a.o

이를 통해 얻은 라이브러리를 CMakeLists.txt와 같은 폴더로 가져오고, 다음과 같이 CMake 스크립트를 수정합니다.

 

... # 이 위로는 지금까지와 동일
target_link_directories(ndkstart PUBLIC .) # 새로 추가됨.

target_link_libraries( # Specifies the target library.
        ndkstart
        oastr # 새로 추가됨.
        # Links the target library to the log library
        # included in the NDK.
        ${log-lib})

 

native-lib.cpp는 이렇게 고쳤습니다.

 

#include <jni.h>
#include <string>

const char* slibstr(); // 새로 추가

extern "C" JNIEXPORT jstring JNICALL
Java_onart_pack_ndkstart_MainActivity_stringFromJNI(
        JNIEnv* env,
        jobject/* this */) {
    return env->NewStringUTF(slibstr()); // 수정
}

 

이렇게 하니까 저는 잘 되었습니다. 즉 미리 빌드된 라이브러리를 사용하려면 리눅스, arm을 타겟으로 해야겠군요. 저 같은 경우 이 NDK를 위해 Clang이 사용되기 때문에 이 역시 clang으로 컴파일했는데 gcc로 하면 되는지는 잘 모르겠습니다. gcc의 크로스 컴파일 과정이 clang보다 까다로운 것 같아서 안 했거든요.

 

3.  Vulkan 프로그램을 만들어 안드로이드에서 실행해 보기

바로 Vulkan 프로그램을 만들어 안드로이드에서 실행해 보고 싶지만 한 가지 알고 넘어가야 할 점이 있었죠? 바로 Window System Integration입니다. 즉 안드로이드 프로그램의 그래픽 창에서 창 표면(window surface)을, 창 표면에서 스왑체인을, 스왑체인에서 프레임버퍼가 쓸 이미지 뷰를 얻는 겁니다. 원래 이걸 공식 문서에서 알려줘야 하는데, 뭔가 깔끔하게 이렇다고 말해주는 내용이 공식에 없습니다. (이게 없거나 제가 핑프거나..) 엄밀히 말하면 크로노스 스펙에는 관련 내용이 아예 없지는 않은데  (1.3 스펙 기준 33장, 1.0 스펙 기준 부록 E) 확실히 설명은 아닙니다.

 

3.1. 링킹 라이브러리

아래 나오는 기능들을 사용하기 위해서는 target_link_libraries에 vulkan과 android를 추가해야 합니다. 이를 위해 별도로 어떤 파일을 넣을 필요는 없습니다.

target_link_libraries( # Specifies the target library.
        ndkstart
        vulkan
        android
        # Links the target library to the log library
        # included in the NDK.
        ${log-lib})

 

3.2. Vulkan - Android WSI

먼저 창 표면(window surface)을 만들어야겠죠. 일단 안드로이드 확장을 위해 VK_KHR_android_surface와 VK_KHR_surface 확장을 인스턴스 생성 시 포함해야 합니다.

그리고 VkAndroidSurfaceCreateInfoKHR 구조체를 주고 VkCreateAndroidSurfaceKHR 함수를 호출해야 하는데, 해당 인포 구조체에서 안드로이드 플랫폼에 종속적으로 하여 얻을 수 있는 녀석이 바로 struct ANativeWindow입니다. 이것은 #include<android/native_window.h>로 포함할 수 있고, Java 측에서는 android.view.Surface에 해당한다고 합니다. 이건 안드로이드 스튜디오의 뷰를 직접 구성하는 경우, SurfaceView 객체를 배치하여 얻으면 됩니다. (참고. 말이 참고지 웬만하면 보고 오세요.)

SurfaceView는 위와 같이 쉽게 찾아서 넣을 수 있습니다. 컨트롤러 코드는 이렇습니다. 편의상 전부 보여드리겠습니다. (아래에 최신 버전 안드로이드 스튜디오만 있으면 실행할 수 있는 프로젝트 전체를 올려 놓았습니다.)

 

package onart.pack.ndkstart;

import androidx.annotation.NonNull;
import androidx.appcompat.app.AppCompatActivity;

import android.content.res.AssetManager;
import android.graphics.Rect;
import android.os.Bundle;
import android.view.Surface;
import android.view.SurfaceHolder;
import android.widget.TextView;

import onart.pack.ndkstart.databinding.ActivityMainBinding;

public class MainActivity extends AppCompatActivity {

    static {
        System.loadLibrary("ndkstart");
    }

    private ActivityMainBinding binding;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        binding = ActivityMainBinding.inflate(getLayoutInflater());
        setContentView(binding.getRoot());

        TextView tv = binding.sampleText;
        tv.setText(stringFromJNI());
        binding.surfaceView.getHolder().addCallback(new SurfaceHolder.Callback() {
            @Override
            public void surfaceCreated(@NonNull SurfaceHolder surfaceHolder) {
                Rect frm = surfaceHolder.getSurfaceFrame();
                initVk(surfaceHolder.getSurface(),frm.width(),frm.height());
            }

            @Override
            public void surfaceChanged(@NonNull SurfaceHolder surfaceHolder, int i, int i1, int i2) {

            }

            @Override
            public void surfaceDestroyed(@NonNull SurfaceHolder surfaceHolder) {

            }
        });

    }

    @Override
    protected void onDestroy() {
        super.onDestroy();
    }

    public native void giveAccess(AssetManager mgr);
    public native String stringFromJNI();
    private native void initVk(Surface sf, int width, int height);
    private native void finalizeVk();
}

 

당연히 눈여겨볼 만한 곳은 binding.surfaceView.getHolder().addCallback 부분입니다. surfaceCreated 함수를 보면 네이티브 함수인 initVk에 Surface 객체와 해상도를 전달하고 있습니다. 이 객체로부터 VkSurfaceKHR를 얻어야 합니다.

 

기본적으로 한 번 만든 창은 그 크기가 변하는 게 아닌 이상 계속 사용할 수 있습니다. 안드로이드 응용의 경우 화면을 회전시키면 UI는 자체가 파괴되고 다시 생성되는 모양인데, 당연히 surface 역시 함께 재생성됩니다. 이 점에 유의하여 회전을 아예 막아 버리거나(180도 회전은 destroy 함수가 호출되지 않음) 그 변화에 맞는 콜백을 호출하게 하는 것이 바람직합니다. 그 부분은 다음으로 미루겠습니다.

 

initVk는 2편의 초기화 함수와 거의 동일합니다. 이러면 알아보기 힘들어진다는 거 잘 알고 있지만, 한 번에 올리자면 너무 기니 차이점만 중심으로 설명하겠습니다.

 

initVk 함수는 이렇게 생겼습니다.

 

extern "C"
JNIEXPORT void JNICALL
Java_onart_pack_ndkstart_MainActivity_initVk(JNIEnv *env, jobject thiz, jobject sf, jint width,
                                            jint height) {
    static bool once=false;
    ANativeWindow* window = ANativeWindow_fromSurface(env,sf);
    onart::VkPlayer::hwnd = window;
    onart::VkPlayer::width = width;
    onart::VkPlayer::height = height;
    if(!once){
        onart::VkPlayer::start();
        once=true;
    }
    else{
        onart::VkPlayer::recreateSwapchainAndRedraw();
        __android_log_print(ANDROID_LOG_DEBUG, "VKPLAYER", "\n recreate %d x %d\n",width,height);
    }
}

 

보시면 VkPlayer 클래스에 public 정적 변수가 3개 있는 걸 볼 수 있는데, 이는 어차피 임시 프로그램이라 최대한 간결하게 넣은 것입니다. 2편의 프로그램에서 start는 루프 함수였지만 여기선 아직 스레드 등 해결하지 않은 이슈가 있어 삼각형 하나만 그리고 바로 중단합니다. (단순하게 루프를 돌리면 너무 바빠서 아예 아무 것도 그려지지 않습니다.) 우리가 직접 창 해상도를 정하고 들어가는 게 아니기 때문에(가능하지만 권장되지 않음) width, height 값을 직접 받게 했습니다.

 

아무튼 위에서 설명한 구조체, ANativeWindow를 저런 방법으로 받아올 수 있습니다. 그것을 초기화 중에 사용하는 방식은 아래와 같습니다.  Vulkan 튜토리얼을 어떤 것이든 학습해 보았다면 어려울 것 없습니다.

 

    bool VkPlayer::createWindow() {
        VkAndroidSurfaceCreateInfoKHR info{};
        info.sType = VK_STRUCTURE_TYPE_ANDROID_SURFACE_CREATE_INFO_KHR;
        info.pNext = nullptr;
        info.flags = 0;
        info.window = hwnd;
        PFN_vkCreateAndroidSurfaceKHR vkCreateAndroidSurfaceKHR = (PFN_vkCreateAndroidSurfaceKHR)vkGetInstanceProcAddr(instance, "vkCreateAndroidSurfaceKHR");
        if(!vkCreateAndroidSurfaceKHR || vkCreateAndroidSurfaceKHR(instance, &info, nullptr, &surface) != VK_SUCCESS){
            __android_log_print(ANDROID_LOG_DEBUG, "VKPLAYER", "\nFailed to create window surface\n");
        }
        return true;
    }

    void VkPlayer::destroyWindow() {
        vkDestroySurfaceKHR(instance, surface, nullptr);
        ANativeWindow_release(hwnd);
    }

 

기존의 createWindow에서는 glfw에서 창을 직접 만들고 window surface 역시 그쪽에서 플랫폼마다 따로 있어야 하는 코드를 알아서 해결해 주었었는데요, 이번에는 이미 있는 surfaceView를 가지고 vkSurfaceKHR를 만듭니다. 이 확장 함수는 vkGetInstanceProcAddr 함수를 이용하여 런타임에 불러와야 합니다. PFN_vkCreateAndroidSurfaceKHR은 그 함수 포인터 형식을 typedef로 정의한 겁니다. 마지막으로 NativeWindow를 더 이상 사용하지 않는다면 ANativeWindow_release 함수를 호출해야 합니다.

 

이를 성공적으로 수행하기 위해서는 인스턴스를 생성할 때 그에 관련한 확장을 추가해야 합니다. 이렇게 2개만 있으면 일단 됩니다. 안드로이드에서 사용할 수 있는 다른 확장은 여기를 참고하세요.

 

{"VK_KHR_surface", "VK_KHR_android_surface"};

 

그 외에는 스왑체인 재생성 시 window surface까지 같이 재생성하게 만든 것 말고 주요하게 달라진 부분은 없습니다. 셰이더는 따로 SPIR-V로 컴파일한 다음 char 배열로 인코딩해 놓았습니다. 그래서 실행하면 일단 이렇게 되는데요,

 

 

 

세로 버전은 원하는 대로 잘 나오는데 가로 버전은 뭔가 이상합니다. 시계 방향으로 90도 돌아간 모습을 보이고 있네요. 당연히 반시계 방향으로 돌리면 머리(파란색)가 왼쪽을 향하게 되고요, 가로 상태에서 단번에 180도 회전시키면 방향이 바뀌지 않습니다. 스왑체인 생성 시에 아래와 같은 옵션을 주면 해결되는데, 더 자세한 사항은 다음 글에서 다루겠습니다.

 

info.preTransform =  VK_SURFACE_TRANSFORM_IDENTITY_BIT_KHR;

 

전체 코드 및 안드로이드 스튜디오 프로젝트는 바로 여기에 올려 두었습니다. 이번 글은 여기서 마칩니다. 더 필요한 내용이라면 Java 계층과 공존하기 위해 필요한 스레드 상의 루프 및 기타 방법, 환경 특성을 고려하는 것, 모든 자원을 해제할 만한  적절한 시점(가로 또는 세로 모드로 고정하는 경우 거의 필요 없음), 크로노스 그룹 등의 샘플에서 주목할 만한 점 정도가 되겠네요. Vulkan과 직접적인 관련은 없으나 안드로이드 입력 시스템과의 연계도 함께 다루면 좋을 것 같습니다.

 

NdkStart.zip
0.56MB

 

역시 안드로이드 프로그래밍 쪽을 잘 모른다는 점이 가장 힘든 요소긴 합니다.  PC 버전에 비해 바뀐 코드는 별로 없지만 고작 그걸 위해 몇 시간을 찾아봐서 피곤하네요.

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

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