Android:将您的 Vulkan 应用带到移动端

      +

      简介

      在上一章中,我们探讨了 Vulkan 配置文件如何简化功能检测并使代码更易于维护。现在,让我们通过将 Vulkan 应用带到 Android 移动端,进一步扩展我们的 Vulkan 知识。

      虽然 Vulkan 从一开始就设计为跨平台,但在 Android 上部署会带来一些新的挑战和机遇。核心 Vulkan API 保持不变,但周围的生态系统(从窗口管理到构建系统)需要不同的方法。

      本章将指导您如何将 Vulkan 应用适配到 Android,尽可能重用代码,同时解决平台特定的需求。您将看到,通过正确的设置,可以维护一个适用于桌面和移动平台的单一代码库。

      Android 特定注意事项

      在深入实现细节之前,让我们先了解开发 Vulkan 应用在 Android 上与桌面平台的主要区别:

      1. 窗口系统集成:不再使用 GLFW,而是使用 Android 的原生窗口系统和活动生命周期。

      2. 应用生命周期:Android 应用可以随时被系统暂停、恢复或终止,需要谨慎管理资源。

      3. 资源加载:资源打包在 APK 文件中,通过 Android 的资产管理器访问。

      4. 构建系统:我们使用 Gradle 和 CMake 共同构建 Android 应用。

      5. 输入处理:触摸输入取代了鼠标和键盘,需要不同的事件处理方式。

      这些差异乍一看可能令人望而生畏,但通过正确的方法,我们可以在保持代码整洁和可维护性的同时解决这些问题。

      项目设置

      现在我们已经了解了关键差异,接下来设置我们的 Android 项目。我们的目标是尽可能重用桌面实现的代码,同时满足 Android 特定的需求。

      先决条件

      在开始之前,请确保已安装以下工具:

      • Android Studio:Android 开发的官方 IDE

      • Android NDK(原生开发工具包):支持在 Android 上进行原生 C++ 开发

      • Android SDK:使用较新的 API 级别(24+,对应 Android 7.0 或更高版本)以支持 Vulkan

      • CMake 和 Ninja 构建工具:用于构建原生代码(可通过 Android Studio 安装)

      • Vulkan SDK:用于着色器编译工具和验证层

      与桌面环境不同,Vulkan HPP(Vulkan 的 C++ 绑定)默认不包含在 Android NDK 中。您需要从 Vulkan-Hpp GitHub 仓库 单独下载,或使用 Vulkan SDK 中包含的版本。

      项目结构

      让我们从了解 Android 项目的结构开始。我们将遵循标准的 Android 应用结构,但进行一些修改以高效地重用主项目中的代码:

      android/
      ├── app/
      │   ├── build.gradle            // 应用级构建配置
      │   ├── src/
      │   │   ├── main/
      │   │   │   ├── AndroidManifest.xml  // 应用清单
      │   │   │   ├── cpp/                 // 原生代码
      │   │   │   │   ├── CMakeLists.txt   // CMake 构建脚本
      │   │   │   │   └── game_activity_bridge.cpp // GameActivity 与 Vulkan 代码的桥梁
      │   │   │   ├── java/                // Java 代码
      │   │   │   │   └── com/example/vulkantutorial/
      │   │   │   │       └── VulkanActivity.java // 主活动(继承自 GameActivity)
      │   │   │   └── res/                 // 资源
      │   │   │       └── values/
      │   │   │           ├── strings.xml  // 字符串资源
      │   │   │           └── styles.xml   // 样式资源
      ├── build.gradle                // 项目级构建配置
      ├── gradle/                     // Gradle 包装器
      ├── settings.gradle             // 项目设置

      设置 Android 项目

      有了项目结构后,让我们深入了解 Android Vulkan 应用的关键组件。我们将从基本配置文件开始,然后转向原生代码实现。

      清单文件

      每个 Android 应用都需要一个清单文件来声明应用的重要信息。对于 Vulkan 应用,AndroidManifest.xml 文件尤为重要,因为它指定了 Vulkan 版本要求:

      <?xml version="1.0" encoding="utf-8"?>
      <manifest xmlns:android="http://schemas.android.com/apk/res/android"
          package="com.vulkan.tutorial">
      
          <!-- Vulkan 需要 API 级别 24(Android 7.0)或更高 -->
          <uses-sdk android:minSdkVersion="24" />
      
          <!-- 声明此应用使用 Vulkan -->
          <uses-feature android:name="android.hardware.vulkan.version" android:version="0x400003" android:required="true" />
          <uses-feature android:name="android.hardware.vulkan.level" android:version="0" android:required="true" />
      
          <application
              android:allowBackup="true"
              android:icon="@mipmap/ic_launcher"
              android:label="@string/app_name"
              android:roundIcon="@mipmap/ic_launcher_round"
              android:supportsRtl="true"
              android:theme="@style/AppTheme">
              <activity
                  android:name=".VulkanActivity"
                  android:label="@string/app_name"
                  android:configChanges="orientation|keyboardHidden|screenSize"
                  android:exported="true">
                  <intent-filter>
                      <action android:name="android.intent.action.MAIN" />
                      <category android:name="android.intent.category.LAUNCHER" />
                  </intent-filter>
              </activity>
          </application>
      
      </manifest>

      关键点:

      • 我们指定最低 SDK 版本为 24(Android 7.0),这是 Vulkan 支持的最低要求。

      • 我们声明应用使用 Vulkan,并指定版本要求。

      • 我们设置主活动(VulkanActivity)为应用的入口点。

      Java 活动

      配置清单后,我们需要创建应用的 Java 部分。虽然大部分 Vulkan 代码将在原生 C++ 中运行,但我们仍然需要一个 Java 活动作为应用的入口点。

      对于 Vulkan 应用,我们将使用 Android Game SDK 中的 GameActivity,而不是传统的 NativeActivity。这种现代方法提供了更好的性能,并专为游戏和图形密集型应用设计:

      package com.vulkan.tutorial;
      
      import android.os.Bundle;
      import android.view.WindowManager;
      import com.google.androidgamesdk.GameActivity;
      
      public class VulkanActivity extends GameActivity {
          @Override
          protected void onCreate(Bundle savedInstanceState) {
              super.onCreate(savedInstanceState);
      
              // 应用运行时保持屏幕常亮
              getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
          }
      
          // 加载原生库
          static {
              System.loadLibrary("vulkan_tutorial_android");
          }
      }

      关键点:

      • 我们继承自 Android Game SDK 中的 GameActivity,它提供了 Java 和原生代码之间更优化的桥梁。

      • GameActivity 为游戏和图形密集型应用提供了比 NativeActivity 更好的性能。

      • 我们加载包含 Vulkan 实现的原生库("vulkan_tutorial_android")。

      构建配置

      有了 Java 活动后,我们需要配置构建过程。Android 使用 Gradle 作为构建系统,我们将配置它以与原生 Vulkan 代码和资源协同工作。

      构建配置分布在多个文件中,各自有不同的职责:

      项目级 build.gradle:

      buildscript {
          repositories {
              google()
              mavenCentral()
          }
          dependencies {
              classpath 'com.android.tools.build:gradle:7.2.2'
          }
      }
      
      allprojects {
          repositories {
              google()
              mavenCentral()
          }
      }
      
      task clean(type: Delete) {
          delete rootProject.buildDir
      }

      应用级 build.gradle:

      plugins {
          id 'com.android.application'
      }
      
      android {
          compileSdkVersion 33
          defaultConfig {
              applicationId "com.vulkan.tutorial"
              minSdkVersion 24
              targetSdkVersion 33
              versionCode 1
              versionName "1.0"
          }
      
          buildTypes {
              release {
                  minifyEnabled false
                  proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
              }
          }
      
          compileOptions {
              sourceCompatibility JavaVersion.VERSION_1_8
              targetCompatibility JavaVersion.VERSION_1_8
          }
      
          externalNativeBuild {
              cmake {
                  path "src/main/cpp/CMakeLists.txt"
                  version "3.22.1"
              }
          }
      
          ndkVersion "25.2.9519653"
      
          // 使用主项目的资源和本地编译的着色器
          sourceSets {
              main {
                  assets {
                      srcDirs = [
                          // 指向主项目的资源
                          '../../../../',  // 附件目录中的模型和纹理
                          // 使用构建目录中本地编译的着色器
                          '.externalNativeBuild/cmake/debug/arm64-v8a/shaders',
                          '.externalNativeBuild/cmake/debug/armeabi-v7a/shaders',
                          '.externalNativeBuild/cmake/debug/x86/shaders',
                          '.externalNativeBuild/cmake/debug/x86_64/shaders',
                          // 也包括发布构建路径
                          '.externalNativeBuild/cmake/release/arm64-v8a/shaders',
                          '.externalNativeBuild/cmake/release/armeabi-v7a/shaders',
                          '.externalNativeBuild/cmake/release/x86/shaders',
                          '.externalNativeBuild/cmake/release/x86_64/shaders'
                      ]
                  }
              }
          }
      }
      
      dependencies {
          implementation 'androidx.appcompat:appcompat:1.6.1'
          implementation 'com.google.android.material:material:1.9.0'
          implementation 'com.google.androidgamesdk:game-activity:1.2.0'
      }

      关键点:

      • 我们指定最低 SDK 版本为 24(Android 7.0)以支持 Vulkan。

      • 我们配置 CMake 来构建原生代码。

      • 我们包含 game-activity 依赖以获得更好的性能。

      • 我们设置资源目录以引用主项目的资源和本地编译的着色器。

      • 这种方法避免了资源重复,并确保我们使用最新版本。

      CMake 配置

      虽然 Gradle 处理整个 Android 构建过程,但我们使用 CMake 来构建原生 C++ 代码。在这里,我们将设置 Vulkan 环境、编译着色器并链接必要的库。

      让我们检查 CMakeLists.txt 文件,这是我们原生代码配置的核心:

      cmake_minimum_required(VERSION 3.22.1)
      
      project(vulkan_tutorial_android)
      
      # 设置主 CMakeLists.txt 的路径
      set(MAIN_CMAKE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/../../../../../../CMakeLists.txt")
      
      # 查找 Vulkan 包
      find_package(Vulkan REQUIRED)
      
      # 设置着色器编译工具
      add_executable(glslang::validator IMPORTED)
      find_program(GLSLANG_VALIDATOR "glslangValidator" HINTS $ENV{VULKAN_SDK}/bin REQUIRED)
      set_property(TARGET glslang::validator PROPERTY IMPORTED_LOCATION "${GLSLANG_VALIDATOR}")
      
      # 定义着色器构建函数
      function(add_shaders_target TARGET)
        cmake_parse_arguments("SHADER" "" "CHAPTER_NAME" "SOURCES" ${ARGN})
        set(SHADERS_DIR ${SHADER_CHAPTER_NAME}/shaders)
        add_custom_command(
          OUTPUT ${SHADERS_DIR}
          COMMAND ${CMAKE_COMMAND} -E make_directory ${SHADERS_DIR}
        )
        add_custom_command(
          OUTPUT ${SHADERS_DIR}/frag.spv ${SHADERS_DIR}/vert.spv
          COMMAND glslang::validator
          ARGS --target-env vulkan1.0 ${SHADER_SOURCES} --quiet
          WORKING_DIRECTORY ${SHADERS_DIR}
          DEPENDS ${SHADERS_DIR} ${SHADER_SOURCES}
          COMMENT "Compiling Shaders"
          VERBATIM
        )
        add_custom_target(${TARGET} DEPENDS ${SHADERS_DIR}/frag.spv ${SHADERS_DIR}/vert.spv)
      endfunction()
      
      # 包含 game-activity 库
      find_package(game-activity REQUIRED CONFIG)
      include_directories(${ANDROID_NDK}/sources/android/game-activity/include)
      
      # 设置 C++ 标准以匹配主项目
      set(CMAKE_CXX_STANDARD 20)
      set(CMAKE_CXX_STANDARD_REQUIRED ON)
      
      # 添加 Vulkan C++ 模块
      add_library(VulkanCppModule SHARED)
      target_compile_definitions(VulkanCppModule
          PUBLIC VULKAN_HPP_DISPATCH_LOADER_DYNAMIC=1 VULKAN_HPP_NO_STRUCT_CONSTRUCTORS=1
      )
      target_include_directories(VulkanCppModule
          PRIVATE
          "${Vulkan_INCLUDE_DIR}"
      )
      target_link_libraries(VulkanCppModule
          PUBLIC
          ${Vulkan_LIBRARIES}
      )
      set_target_properties(VulkanCppModule PROPERTIES CXX_STANDARD 20)
      
      # 设置 C++ 模块文件集
      target_sources(VulkanCppModule
          PUBLIC
          FILE_SET cxx_modules TYPE CXX_MODULES
          BASE_DIRS
          "${Vulkan_INCLUDE_DIR}"
          FILES
          "${Vulkan_INCLUDE_DIR}/vulkan/vulkan.cppm"
      )
      
      # 为 34_android 设置着色器编译
      set(SHADER_SOURCE_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../../../../../../attachments")
      set(SHADER_OUTPUT_DIR "${CMAKE_CURRENT_BINARY_DIR}/shaders")
      file(MAKE_DIRECTORY ${SHADER_OUTPUT_DIR})
      
      # 将着色器源文件复制到构建目录
      configure_file(
          "${SHADER_SOURCE_DIR}/27_shader_depth.frag"
          "${SHADER_OUTPUT_DIR}/27_shader_depth.frag"
          COPYONLY
      )
      configure_file(
          "${SHADER_SOURCE_DIR}/27_shader_depth.vert"
          "${SHADER_OUTPUT_DIR}/27_shader_depth.vert"
          COPYONLY
      )
      
      # 编译着色器
      set(SHADER_SOURCES "${SHADER_OUTPUT_DIR}/27_shader_depth.frag" "${SHADER_OUTPUT_DIR}/27_shader_depth.vert")
      add_shaders_target(android_shaders CHAPTER_NAME "${SHADER_OUTPUT_DIR}" SOURCES ${SHADER_SOURCES})
      
      # 添加主原生库
      add_library(vulkan_tutorial_android SHARED
          ${CMAKE_CURRENT_SOURCE_DIR}/../../../../../../attachments/34_android.cpp
          game_activity_bridge.cpp
      )
      
      # 添加对着色器编译的依赖
      add_dependencies(vulkan_tutorial_android android_shaders)
      
      # 设置包含目录
      target_include_directories(vulkan_tutorial_android PRIVATE
          ${CMAKE_CURRENT_SOURCE_DIR}
          ${Vulkan_INCLUDE_DIR}
          ${ANDROID_NDK}/sources/android/game-activity/include
      )
      
      # 链接库
      target_link_libraries(vulkan_tutorial_android
          VulkanCppModule
          game-activity::game-activity
          android
          log
          ${Vulkan_LIBRARIES}
      )

      关键点:

      • 我们查找 Vulkan 包并包含 game-activity 库而不是 native_app_glue。

      • 我们设置着色器编译工具并定义一个函数来编译着色器。

      • 我们将 C 标准设置为 C20 并创建一个 Vulkan C++ 模块。

      • 我们为 34_android 章节设置着色器编译,从主项目复制着色器源文件。

      • 我们添加主原生库,它使用主项目中的 34_android.cpp 文件和一个桥接文件来连接 GameActivity。

      • 我们链接必要的库,包括 game-activity。

      原生实现

      现在我们已经设置了构建配置,让我们深入了解为 Android 提供动力的原生 C++ 代码。这是真正的魔法发生的地方 - 我们将看到如何调整现有的 Vulkan 代码以在 Android 上工作,同时尽量减少平台特定的更改。

      我们方法的一个关键优势是代码重用。我们不是为桌面和 Android 维护单独的代码库,而是构建项目以尽可能多地共享代码:

      1. 34_android.cpp:这是主项目中使用的相同文件,包含核心 Vulkan 实现。通过重用此文件,我们确保渲染代码在不同平台上保持一致。

      2. game_activity_bridge.cpp:这个小型桥接文件连接 Android GameActivity 和我们的核心 Vulkan 代码。它处理平台特定的初始化和事件处理。

      这种关注点分离使我们能够专注于 Vulkan 实现,而不会被平台特定的细节所困扰。当我们改进渲染代码时,桌面和 Android 版本都会自动受益。

      GameActivity 桥接

      让我们仔细看看我们的桥接代码,它是连接 Java GameActivity 和原生 Vulkan 实现的关键。这个虽小但至关重要的文件处理 Android 基于 Java 的活动生命周期与我们的 C++ 代码之间的转换:

      #include <game-activity/GameActivity.h>
      #include <game-activity/native_app_glue/android_native_app_glue.h>
      #include <android/log.h>
      
      // 定义日志宏
      #define LOGI(...) ((void)__android_log_print(ANDROID_LOG_INFO, "VulkanTutorial", __VA_ARGS__))
      #define LOGW(...) ((void)__android_log_print(ANDROID_LOG_WARN, "VulkanTutorial", __VA_ARGS__))
      #define LOGE(...) ((void)__android_log_print(ANDROID_LOG_ERROR, "VulkanTutorial", __VA_ARGS__))
      
      // 主入口点的前向声明
      extern "C" void android_main(android_app* app);
      
      // GameActivity 入口点
      extern "C" {
          void GameActivity_onCreate(GameActivity* activity) {
              LOGI("GameActivity_onCreate");
      
              // 创建 android_app 结构
              android_app* app = new android_app();
              memset(app, 0, sizeof(android_app));
      
              // 设置 android_app 结构
              app->activity = activity;
              app->window = activity->window;
      
              // 调用原始的 android_main 函数
              android_main(app);
      
              // 清理
              delete app;
          }
      }

      这段桥接代码:

      1. 创建一个与我们的 Vulkan 代码兼容的 android_app 结构

      2. 设置 GameActivity 和我们的代码之间的必要连接

      3. 调用 34_android.cpp 文件中的 android_main 函数

      Android 入口点

      一旦我们的桥接代码创建了 android_app 结构,它就会调用 android_main 函数,该函数作为我们原生代码的入口点。这个函数定义在我们的 34_android.cpp 文件中,类似于桌面应用中的 main() 函数:

      让我们看看如何从这个入口点初始化 Vulkan 应用:

      void android_main(android_app* app) {
          try {
              // 创建并运行 Vulkan 应用
              HelloTriangleApplication application(app);
              application.run();
          } catch (const std::exception& e) {
              LOGE("Exception caught: %s", e.what());
          }
      }

      创建 Vulkan 表面

      我们 Vulkan 实现中一个关键的平台特定差异是如何创建表面。在桌面上,我们使用 GLFW 创建窗口和表面。在 Android 上,我们需要使用 VK_KHR_android_surface 扩展从原生 Android 窗口创建表面。

      以下是在 Android 上创建 Vulkan 表面的方法:

      void createSurface() {
          VkSurfaceKHR _surface;
          VkResult result = VK_SUCCESS;
      
          // 创建 Android 表面
          result = vkCreateAndroidSurfaceKHR(
              *instance,
              &(VkAndroidSurfaceCreateInfoKHR{
                  .sType = VK_STRUCTURE_TYPE_ANDROID_SURFACE_CREATE_INFO_KHR,
                  .pNext = nullptr,
                  .flags = 0,
                  .window = androidApp->window
              }),
              nullptr,
              &_surface
          );
      
          if (result != VK_SUCCESS) {
              throw std::runtime_error("Failed to create Android surface");
          }
      
          surface = vk::raii::SurfaceKHR(instance, _surface);
      }

      处理 Android 事件

      另一个重要的平台特定方面是事件处理。Android 应用的生命周期与桌面应用不同 - 它们可以随时被系统暂停、恢复或终止。我们需要正确处理这些事件以确保 Vulkan 资源得到正确管理。

      以下是我们如何处理应用中的 Android 特定事件:

      static void handleAppCommand(android_app* app, int32_t cmd) {
          auto* vulkanApp = static_cast<VulkanApplication*>(app->userData);
          switch (cmd) {
              case APP_CMD_INIT_WINDOW:
                  // 窗口创建,初始化 Vulkan
                  if (app->window != nullptr) {
                      vulkanApp->initVulkan();
                  }
                  break;
              case APP_CMD_TERM_WINDOW:
                  // 窗口销毁,清理 Vulkan
                  vulkanApp->cleanup();
                  break;
              default:
                  break;
          }
      }
      
      static int32_t handleInputEvent(android_app* app, AInputEvent* event) {
          auto* vulkanApp = static_cast<VulkanApplication*>(app->userData);
          if (AInputEvent_getType(event) == AINPUT_EVENT_TYPE_MOTION) {
              // 处理触摸事件
              float x = AMotionEvent_getX(event, 0);
              float y = AMotionEvent_getY(event, 0);
      
              // 处理触摸坐标
              // ...
      
              return 1;
          }
          return 0;
      }

      跨平台实现

      虽然到目前为止我们专注于 Android 特定的代码,但我们的方法允许我们维护一个适用于桌面和 Android 平台的单一代码库。这是通过谨慎使用预处理器指令和平台特定抽象来实现的。

      平台检测

      跨平台方法的第一步是检测我们正在为哪个平台构建。我们使用预处理器指令来检查平台特定的预定义宏:

      // 平台检测
      #if defined(__ANDROID__)
          #define PLATFORM_ANDROID 1
      #else
          #define PLATFORM_DESKTOP 1
      #endif

      这种方法利用了标准预定义宏 ANDROID,当为 Android 平台构建时,编译器会自动定义它。这些平台宏随后在整个代码中使用,以有条件地编译平台特定的代码。

      一致的类结构

      为了保持代码库的整洁和一致,我们在两个平台上使用相同的类名(HelloTriangleApplication)。这使得代码更易于理解,并减少了对平台特定分支的需求:

      // 跨平台应用类
      class HelloTriangleApplication {
      public:
      #if PLATFORM_DESKTOP
          // 桌面构造函数
          HelloTriangleApplication() {
              // 不需要 Android 特定的初始化
          }
      #else
          // Android 构造函数
          HelloTriangleApplication(android_app* app) : androidApp(app) {
              // Android 特定的初始化
          }
      #endif
          // ... 类的其余部分 ...
      };

      平台特定的包含

      不同平台需要不同的头文件。我们使用预处理器指令来包含适当的头文件:

      // 平台特定的包含
      #if PLATFORM_ANDROID
          // Android 特定的包含
          #include <android/log.h>
          #include <android_native_app_glue.h>
          #include <android/asset_manager.h>
          #include <android/asset_manager_jni.h>
      #else
          // 桌面特定的包含
          #define GLFW_INCLUDE_VULKAN
          #include <GLFW/glfw3.h>
          #include <stb_image.h>
          #include <tiny_obj_loader.h>
      #endif

      跨平台文件加载

      文件加载是桌面和 Android 平台之间的关键区别之一。在桌面上,我们从文件系统加载文件,而在 Android 上,我们从 APK 的资源中加载它们。我们创建了一个跨平台的文件加载函数,适用于两个平台:

      // 跨平台文件读取函数
      std::vector<char> readFile(const std::string& filename, std::optional<AAssetManager*> assetManager = std::nullopt) {
      #if PLATFORM_ANDROID
          // 在 Android 上,如果提供了资产管理器则使用它
          if (assetManager.has_value() && *assetManager != nullptr) {
              // 打开资产
              AAsset* asset = AAssetManager_open(*assetManager, filename.c_str(), AASSET_MODE_BUFFER);
              // ... 从资产读取文件 ...
              return buffer;
          }
      #endif
      
          // 桌面版本或 Android 回退到文件系统
          std::ifstream file(filename, std::ios::ate | std::ios::binary);
          // ... 从文件系统读取文件 ...
          return buffer;
      }

      平台特定的入口点

      每个平台都有自己的入口点。在桌面上,我们使用标准的 main() 函数,而在 Android 上,我们使用 android_main() 函数:

      // 平台特定的入口点
      #if PLATFORM_ANDROID
      // Android 主入口点
      void android_main(android_app* app) {
          // Android 特定的初始化
          try {
              HelloTriangleApplication vulkanApp(app);
              vulkanApp.run();
          } catch (const std::exception& e) {
              LOGE("Exception caught: %s", e.what());
          }
      }
      #else
      // 桌面主入口点
      int main() {
          try {
              HelloTriangleApplication app;
              app.run();
          } catch (const std::exception& e) {
              std::cerr << e.what() << std::endl;
              return EXIT_FAILURE;
          }
          return EXIT_SUCCESS;
      }
      #endif

      构建系统集成

      我们的跨平台方法利用了编译器内置的平台检测功能。由于 ANDROID 宏在构建 Android 时由编译器自动定义,我们不需要在构建系统中显式定义平台宏。

      这种方法有几个优点:

      1. 简单性:我们不需要在 CMake 文件中维护平台特定的编译定义。

      2. 可靠性:我们依赖于标准的编译器行为,而不是自定义定义。

      3. 可维护性:构建系统配置较少意味着潜在的故障点更少。

      通过使用编译器的预定义宏,我们可以维护一个适用于桌面和 Android 平台的单一代码库,平台特定的代码最少。当我们改进渲染代码时,桌面和 Android 版本都会自动受益。

      Android 上的着色器处理

      现在我们已经介绍了核心原生实现,让我们讨论 Vulkan 开发在 Android 上的另一个重要方面:着色器处理。着色器是任何 Vulkan 应用的关键部分,我们需要确保它们在 Android 上正确编译和加载。

      在我们的方法中,我们在构建过程中本地编译着色器,类似于主项目中的做法。这种策略提供了几个显著的优势:

      1. 一致性:我们为桌面和 Android 构建使用相同的着色器源文件,确保跨平台的视觉效果一致。

      2. 可维护性:当我们需要更新着色器时,只需在一个地方更改,桌面和 Android 版本都会受益。

      3. 构建时验证:着色器编译错误在构建过程中捕获,而不是在运行时,使调试更加容易。

      本地着色器编译

      我们已经设置了 CMake 配置以在构建过程中本地编译着色器:

      1. 定义着色器构建函数

        function(add_shaders_target TARGET)
          cmake_parse_arguments("SHADER" "" "CHAPTER_NAME" "SOURCES" ${ARGN})
          set(SHADERS_DIR ${SHADER_CHAPTER_NAME}/shaders)
          add_custom_command(
            OUTPUT ${SHADERS_DIR}
            COMMAND ${CMAKE_COMMAND} -E make_directory ${SHADERS_DIR}
          )
          add_custom_command(
            OUTPUT ${SHADERS_DIR}/frag.spv ${SHADERS_DIR}/vert.spv
            COMMAND glslang::validator
            ARGS --target-env vulkan1.0 ${SHADER_SOURCES} --quiet
            WORKING_DIRECTORY ${SHADERS_DIR}
            DEPENDS ${SHADERS_DIR} ${SHADER_SOURCES}
            COMMENT "Compiling Shaders"
            VERBATIM
          )
          add_custom_target(${TARGET} DEPENDS ${SHADERS_DIR}/frag.spv ${SHADERS_DIR}/vert.spv)
        endfunction()
      2. 从主项目复制着色器源文件

        # 为 34_android 设置着色器编译
        set(SHADER_SOURCE_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../../../../../../attachments")
        set(SHADER_OUTPUT_DIR "${CMAKE_CURRENT_BINARY_DIR}/shaders")
        file(MAKE_DIRECTORY ${SHADER_OUTPUT_DIR})
        
        # 将着色器源文件复制到构建目录
        configure_file(
            "${SHADER_SOURCE_DIR}/27_shader_depth.frag"
            "${SHADER_OUTPUT_DIR}/27_shader_depth.frag"
            COPYONLY
        )
        configure_file(
            "${SHADER_SOURCE_DIR}/27_shader_depth.vert"
            "${SHADER_OUTPUT_DIR}/27_shader_depth.vert"
            COPYONLY
        )
      3. 编译着色器

        # 编译着色器
        set(SHADER_SOURCES "${SHADER_OUTPUT_DIR}/27_shader_depth.frag" "${SHADER_OUTPUT_DIR}/27_shader_depth.vert")
        add_shaders_target(android_shaders CHAPTER_NAME "${SHADER_OUTPUT_DIR}" SOURCES ${SHADER_SOURCES})
        
        # 添加对着色器编译的依赖
        add_dependencies(vulkan_tutorial_android android_shaders)
      4. 在 Gradle 构建中引用编译的着色器

        sourceSets {
            main {
                assets {
                    srcDirs = [
                        // 指向主项目的资源
                        '../../../../',  // 附件目录中的模型和纹理
                        // 使用构建目录中本地编译的着色器
                        '.externalNativeBuild/cmake/debug/arm64-v8a/shaders',
                        '.externalNativeBuild/cmake/debug/armeabi-v7a/shaders',
                        // ... 其他 ABI ...
                    ]
                }
            }
        }

      跨平台加载资源

      我们统一的 readFile 函数使得跨平台加载资源变得容易。以下是我们如何使用它来加载着色器文件:

      // 使用跨平台函数加载着色器文件
      #if PLATFORM_ANDROID
      std::optional<AAssetManager*> optionalAssetManager = assetManager;
      #else
      std::optional<AAssetManager*> optionalAssetManager = std::nullopt;
      #endif
      std::vector<char> vertShaderCode = readFile("shaders/vert.spv", optionalAssetManager);
      std::vector<char> fragShaderCode = readFile("shaders/frag.spv", optionalAssetManager);

      我们使用相同的方法加载纹理图像和模型文件:

      // 加载纹理图像
      #if PLATFORM_ANDROID
      std::optional<AAssetManager*> optionalAssetManager = assetManager;
      std::vector<char> imageData = readFile(TEXTURE_PATH, optionalAssetManager);
      // 处理图像数据...
      #else
      // 直接从文件系统加载
      // ...
      #endif

      这种统一的方法让我们两全其美:我们为两个平台使用相同的代码结构,平台特定的差异由 readFile 函数本身处理。这使得我们的代码更易于维护和理解。

      构建和运行

      现在我们已经设置了包含所有必要组件的 Android 项目,让我们将所有内容整合起来,在 Android 设备上运行 Vulkan 应用。

      过程很简单:

      1. 在 Android Studio 中打开项目。

      2. 连接 Android 设备或启动模拟器(确保它支持 Vulkan)。

      3. 点击 Android Studio 中的 "Run" 按钮。

      Android Studio 将处理其余部分 - 它将构建应用,编译着色器,将所有内容打包到 APK 中,安装到设备/模拟器上并启动它。如果一切设置正确,您应该会看到 Vulkan 应用在 Android 上运行,渲染的场景与桌面版相同。

      结论

      在本章中,我们探讨了如何通过将 Vulkan 应用适配到 Android,将其从桌面带到移动端。我们已经看到,虽然核心 Vulkan API 在不同平台上保持一致,但周围的生态系统需要平台特定的适配。

      我们的方法展示了几个关键原则,您可以将其应用到自己的 Vulkan 项目中:

      1. 代码重用:通过正确构建项目,我们可以为桌面和 Android 平台使用相同的核心渲染代码(34_android.cpp),最大限度地减少重复和维护开销。

      2. 现代 Android 集成:我们利用 Android Game SDK 中的 GameActivity 获得更好的性能和更简化的集成,而不是使用较旧的 NativeActivity 方法。

      3. 高效的资源管理:我们不是复制资源,而是从主项目中引用它们,确保一致性并减小 APK 大小。

      4. 本地着色器编译:通过在构建过程中编译着色器,我们早期捕获错误并确保跨平台的兼容性。

      5. 最少的平台特定代码:我们将平台特定的代码隔离在一个小型桥接文件中,保持核心 Vulkan 实现的整洁和可移植性。

      这种方法不仅使应用更易于维护和更新,还为未来扩展到其他平台奠定了坚实的基础。当您改进核心渲染代码时,桌面和 Android 版本都会自动受益。

      完整的 Android 示例可以在 attachments/android 目录中找到。您可以自由地将其用作自己 Android 上 Vulkan 项目的模板。

      请记住,Vulkan HPP 默认不包含在 Android NDK 中,因此您需要从 Vulkan-Hpp GitHub 仓库 单独下载,或使用 Vulkan SDK 中包含的版本。