狸村 Mystic Web · 狸村
目录

引言

在上一章中,我们探讨了 Vulkan Profiles 如何简化特性检测并使代码更易于维护。现在,让我们将 Vulkan 知识更进一步,通过 Android 将我们的应用程序带到移动世界。

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

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

Android 特有的注意事项

在深入实现细节之前,让我们了解为 Android 开发 Vulkan 应用程序与桌面相比的关键差异:

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

  2. 应用程序生命周期:Android 应用可能随时被系统暂停、恢复或终止,需要仔细的资源管理。

  3. 资源加载:资源被打包在 APK 文件中,通过 Android 的 AssetManager 访问。

  4. 构建系统:我们同时使用 Gradle 和 CMake 来构建 Android 应用程序。

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

这些差异乍看可能令人望而生畏,但通过正确的方法,我们可以在保持代码库干净、可维护的同时解决它们。

项目设置

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

前置条件

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

  • Android Studio:Android 开发的官方 IDE

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

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

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

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

Important

与桌面环境不同,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 // 主 Activity(继承 GameActivity)
│   │   │   └── res/                 // 资源
│   │   │       └── values/
│   │   │           ├── strings.xml  // 字符串资源
│   │   │           └── styles.xml   // 样式资源
├── build.gradle                // 项目级构建配置
├── gradle/                     // Gradle Wrapper
├── settings.gradle             // 项目设置

设置 Android 项目

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

Manifest 文件

每个 Android 应用程序都需要一个 Manifest 文件,声明有关应用程序的重要信息。对于我们的 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 并指定版本要求。

  • 我们将主 Activity(VulkanActivity)设置为应用程序的入口点。

Java Activity

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

对于我们的 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 与原生代码之间更优化的桥接。

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

  • 我们加载原生库(“vulkan_tutorial_android”),其中包含我们的 Vulkan 实现。

构建配置

有了 Java Activity 之后,我们需要配置构建过程。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 = [
                    // 指向主项目的资源
                    '../../../../',  // 用于 attachments 目录中的模型和纹理
                    // 使用所有 ABI 的构建目录中本地编译的着色器
                    // 这些路径相对于 app 目录
                    '.externalNativeBuild/cmake/debug/arm64-v8a/shaders',
                    '.externalNativeBuild/cmake/debug/armeabi-v7a/shaders',
                    '.externalNativeBuild/cmake/debug/x86/shaders',
                    '.externalNativeBuild/cmake/debug/x86_64/shaders',
                    // 也包含 release 构建路径
                    '.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 CMAKECOMMANDEmakedirectory{CMAKE_COMMAND} -E make_directory{SHADERS_DIR}
  )
  add_custom_command(
    OUTPUT SHADERSDIR/frag.spv{SHADERS_DIR}/frag.spv{SHADERS_DIR}/vert.spv
    COMMAND glslang::validator
    ARGS --target-env vulkan1.0 ${SHADER_SOURCES} --quiet
    WORKING_DIRECTORY ${SHADERS_DIR}
    DEPENDS SHADERSDIR{SHADERS_DIR}{SHADER_SOURCES}
    COMMENT "Compiling Shaders"
    VERBATIM
  )
  add_custom_target(TARGETDEPENDS{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)

# (可选)当 ENABLE_CPP20_MODULE=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 "SHADEROUTPUTDIR/27shaderdepth.frag""{SHADER_OUTPUT_DIR}/27_shader_depth.frag" "{SHADER_OUTPUT_DIR}/27_shader_depth.vert")
add_shaders_target(android_shaders CHAPTER_NAME "SHADEROUTPUTDIR"SOURCES{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 上 Vulkan 应用程序的原生 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 的 Activity 生命周期与我们的 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 Surface

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

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

void createSurface() {
    VkSurfaceKHR _surface;
    VkResult result = VK_SUCCESS;

    // 创建 Android Surface
    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 上的着色器处理

现在我们已经涵盖了核心原生实现,让我们解决 Android 上 Vulkan 开发的另一个重要方面:着色器处理。着色器是任何 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 CMAKECOMMANDEmakedirectory{CMAKE_COMMAND} -E make_directory{SHADERS_DIR}
      )
      add_custom_command(
        OUTPUT SHADERSDIR/frag.spv{SHADERS_DIR}/frag.spv{SHADERS_DIR}/vert.spv
        COMMAND glslang::validator
        ARGS --target-env vulkan1.0 ${SHADER_SOURCES} --quiet
        WORKING_DIRECTORY ${SHADERS_DIR}
        DEPENDS SHADERSDIR{SHADERS_DIR}{SHADER_SOURCES}
        COMMENT "Compiling Shaders"
        VERBATIM
      )
      add_custom_target(TARGETDEPENDS{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 "SHADEROUTPUTDIR/27shaderdepth.frag""{SHADER_OUTPUT_DIR}/27_shader_depth.frag" "{SHADER_OUTPUT_DIR}/27_shader_depth.vert")
    add_shaders_target(android_shaders CHAPTER_NAME "SHADEROUTPUTDIR"SOURCES{SHADER_OUTPUT_DIR}" SOURCES{SHADER_SOURCES})
    
    # 添加对着色器编译的依赖
    add_dependencies(vulkan_tutorial_android android_shaders)
  4. 在 Gradle 构建中引用编译后的着色器

    sourceSets {
        main {
            assets {
                srcDirs = [
                    // 指向主项目的资源
                    '../../../../',  // 用于 attachments 目录中的模型和纹理
                    // 使用所有 ABI 的构建目录中本地编译的着色器
                    '.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 中包含的版本。

Vulkan 是 Khronos Group Inc. 的注册商标

教程内容版权归原作者,遵循 CC BY-SA 4.0;本站独立代码及设计除外。