wgpu 與 Android App 集成

開發(fā)環(huán)境配置

假設你的電腦上已經(jīng)安裝了 Android Studio,從菜單欄打開 SDK 管理器(Tools > SDK Manager > Android SDK > SDK Tools),勾選以下 3 個選項后點擊 OK 按鈕確認:

  • Android SDK Build-Tools
  • Android SDK Command-line Tools
  • NDK(Side by side)
SDK Tools

然后,設置如下兩個系統(tǒng)環(huán)境變量:

export ANDROID_SDK_ROOT=$HOME/Library/Android/sdk
# 注意,此處需要替換為你電腦上安裝的 NDK 的版本號
export NDK_HOME=$ANDROID_SDK_ROOT/ndk/23.1.7779620

添加安卓構建目標支持

到目前為止,Android 模擬器和虛擬設備還不支持 Vulkan 圖形 API(僅支持 OpenGL ES),所以開發(fā)或調(diào)試 wgpu 程序在 Android 系統(tǒng)上的運行時,建議使用真機(各種云測平臺的云真機也行)。

如果需要支持模擬器運行,還得加上 x86_64-linux-androidi686-linux-android 這兩個構建目標的支持。需要注意的是,如果指定了 wgpu 項目使用 Vulkan 圖形后端(Instance::new(wgpu::Backends::VULKAN)),則在模擬內(nèi)運行時會崩潰:

rustup target add aarch64-linux-android armv7-linux-androideabi

自定義窗口對象

要實現(xiàn)一個 wgpu 里能使用的窗口對象,就必須實現(xiàn) raw-window-handle 中 raw_window_handle() raw_display_handle() 這兩個分別定義在 HasRawWindowHandle HasRawDisplayHandle trait 里的抽象接口。

實現(xiàn) raw_display_handle() 最為簡單, 只需要實例化一個空的 AndroidDisplayHandle 對象做為參數(shù)。查看 raw-window-handle 的源碼就會發(fā)現(xiàn),實現(xiàn) raw_window_handle() 抽象接口需要用到 AndroidNdkWindowHandle 對象,此對象有一個叫 a_native_window 的字段,用來指向安卓 App 的 ANativeWindow 實例。
下面我們來一步步實現(xiàn)它。

先給項目添加必要的依賴:

[target.'cfg(target_os = "android")'.dependencies]
jni = "0.19"
# 星號表示不鎖定特定版本,在項目構建及運行時始終保持使用最新版本
ndk-sys = "*"
raw-window-handle = "0.5"

然后定義一個 NativeWindow 結構體,它只有一個叫 a_native_window 的字段:

struct NativeWindow {
    a_native_window: *mut ndk_sys::ANativeWindow,
}
impl NativeWindow {
    // env 和 surface 都是安卓端傳遞過來的參數(shù)
    fn new(env: *mut JNIEnv, surface: jobject) -> Self {
        let a_native_window = unsafe {
            // 獲取與安卓端 surface 對象關聯(lián)的 ANativeWindow,以便能通過 Rust 與之交互。
            // 此函數(shù)在返回 ANativeWindow 的同時會自動將其引用計數(shù) +1,以防止該對象在安卓端被意外釋放。
            ndk_sys::ANativeWindow_fromSurface(env as *mut _, surface as *mut _)
        };
        Self { a_native_window }
    }
}

最后給 NativeWindow 實現(xiàn) raw-window-handle 抽象接口:

unsafe impl HasRawWindowHandle for NativeWindow {
    fn raw_window_handle(&self) -> RawWindowHandle {
        let mut handle = AndroidNdkWindowHandle::empty();
        handle.a_native_window = self.a_native_window as *mut _ as *mut c_void;
        RawWindowHandle::AndroidNdk(handle)
    }
}

unsafe impl HasRawDisplayHandle for NativeWindow {
    fn raw_display_handle(&self) -> RawDisplayHandle {
        RawDisplayHandle::Android(AndroidDisplayHandle::empty())
    }
}

查看自定義窗口對象的完整源碼!

定義 FFI

Rust 有一個關鍵字 extern(kotlin 中定義 JNI 函數(shù)時也有一個對應的關鍵字叫 external, 我們接下來會用到),當需要與其他語言編寫的代碼進行交互時,用于創(chuàng)建和使用外部函數(shù)接口(FFI,F(xiàn)oreign Function Interface)。FFI 是一種編程語言定義函數(shù)的方式,可以讓不同的 ”外部“ 編程語言調(diào)用這些函數(shù)。

在 Rust 這一端,我們通過給公開函數(shù)添加 #[no_mangle] 屬性來允許安卓端調(diào)用此函數(shù):

#[no_mangle]
#[jni_fn("name.jinleili.wgpu.RustBridge")]
pub fn createWgpuCanvas(env: *mut JNIEnv, _: JClass, surface: jobject, idx: jint) -> jlong {
    android_logger::init_once(Config::default().with_min_level(Level::Trace));
    let canvas = WgpuCanvas::new(AppSurface::new(env as *mut _, surface), idx as i32);
    info!("WgpuCanvas created!");
    // 使用 Box 對 Rust 對象進行裝箱操作。
    // 我們無法將 Rust 對象直接傳遞給外部語言,通過裝箱來傳遞此對象的裸指針 
    // into_raw 返回指針的同時,也將此對象的內(nèi)存管理權轉交給調(diào)用方
    Box::into_raw(Box::new(canvas)) as jlong
}

#[no_mangle]
#[jni_fn("name.jinleili.wgpu.RustBridge")]
pub fn enterFrame(_env: *mut JNIEnv, _: JClass, obj: jlong) {
    // 直接獲取到指針指代的 Rust 對象的可變借用
    let obj = unsafe { &mut *(obj as *mut WgpuCanvas) };
    obj.enter_frame();
}

#[no_mangle] 屬性告訴 Rust 關閉函數(shù)名稱修改功能。如果不加這個屬性,Rust 編譯器就會修改函數(shù)名,這是現(xiàn)代編譯器為了解決唯?名稱解析引起的各種問題所引?的技術。如果函數(shù)名被修改了,外部編程語言就?法按原名稱調(diào)?,開發(fā)者也沒辦法知道修改后的函數(shù)名。

#[jni_fn("XXX")] 這個函數(shù)簽名屬性需要重點介紹一下,做過安卓 JNI 開發(fā)的都知道,JNI 函數(shù)的簽名是又臭又長,比如上面的 createWgpuCanvas 函數(shù),手寫符合 JNI 規(guī)范的函數(shù)簽名就會是 Java_name_jinleili_wgpu_RustBridge_createWgpuCanvas 這樣,難寫且難維護 #[jni_fn("name.jinleili.wgpu.RustBridge")] 這個屬性能自動幫我們生成兼容 JNI 的函數(shù)簽名,使正確編寫函數(shù)簽名變得更加容易。為此,我們需要 jni_fn 依賴項:

[target.'cfg(target_os = "android")'.dependencies]
jni_fn = "0.1"
# 其它依賴項

在安卓端,我們定義一個命名空間為 name.jinleili.wgpuRustBridge 類來加載 Rust 程序,并使用 external 關鍵字標記好具體實現(xiàn)在 Rust 端的外部函數(shù)聲明:

package name.jinleili.wgpu

import android.view.Surface

class RustBridge {
    init {
        System.loadLibrary("wgpu_in_app")
    }

    external fun createWgpuCanvas(surface: Surface, idx: Int): Long
    external fun enterFrame(rustObj: Long)
    // ...
}

你可以使用任意符合安卓規(guī)范的命名空間,只需要記得讓 Rust 端 #[jni_fn("")] 屬性里的字符串與安卓端代碼里的命名空間一致。

實現(xiàn) cargo so 子命令

實現(xiàn) so 子命令的目的是為了一勞永逸地解決 Rust 項目配置 Android NDK 鏈接的問題。如果你對如何給 wgpu 項目手動配置 NDK 感興趣,Mozilla 的這篇文章有詳細的步驟。 so 子命令的代碼非常簡單,而且我已經(jīng)將它發(fā)布到了 Rust 的包注冊網(wǎng)站 crates.io, 可以直接安裝使用:

let args = std::env::args();
match Subcommand::new(args, "so", |_, _| Ok(false)) {
    Ok(cmd) => match cmd.cmd() {
        "build" | "b" => {
            let ndk = Ndk::from_env().unwrap();
            let build_targets = if let Some(target) = cmd.target() {
                vec![Target::from_rust_triple(target).ok().unwrap()]
            } else {
                vec![
                    Target::Arm64V8a,
                    Target::ArmV7a,
                    Target::X86,
                    Target::X86_64,
                ]
            };
            for target in build_targets {
                let triple = target.rust_triple();
                // setting ar, linker value
                let mut cargo = cargo_ndk(&ndk, target, 24).unwrap();
                cargo.arg("rustc");
                if cmd.target().is_none() {
                    cargo.arg("--target").arg(triple);
                }
                cargo.args(cmd.args());
                if ndk.build_tag() > 7272597 {
                    if !cmd.args().contains(&"--".to_owned()) {
                        cargo.arg("--");
                    }
                    let gcc_link_dir = cmd.target_dir().join("gcc-temp-extra-link-libraries");
                    let _ = std::fs::create_dir_all(&gcc_link_dir);
                    std::fs::write(gcc_link_dir.join("libgcc.a"), "INPUT(-lunwind)")
                        .expect("Failed to write");
                    cargo.arg("-L").arg(gcc_link_dir);
                }

                if !cargo.status().unwrap().success() {
                    println!("{:?}", NdkError::CmdFailed(cargo));
                }
            }
        }
        _ => {}
    },
    Err(_) => {}
};

查看 cargo-so 源碼!

編譯為 .so 庫文件

首先,安裝我們上面實現(xiàn) so 子命令:

# 從 crates.io 安裝
cargo install cargo-so
# 或者
# 也可以從源碼安裝
cargo install --path ./cargo-so

然后,使用 so 子命令來構建 wgpu 項目:

# 將 wgpu 程序構建為 Android .so 庫文件
cargo so b --lib --target aarch64-linux-android --release
cargo so b --lib --target armv7-linux-androideabi --release

# 將 .so 復制到安卓項目的 jniLibs/ 目錄下
cp target/aarch64-linux-android/release/libwgpu_in_app.so android/app/libs/arm64-v8a/libwgpu_in_app.so
cp target/armv7-linux-androideabi/release/libwgpu_in_app.so android/app/libs/armeabi-v7a/libwgpu_in_app.so

我們還可以上面的構建與復制命令放進一個 .sh 命令行文件內(nèi),之后編譯項目時只需要執(zhí)行此命令行文件就可以了:

sh ./android_lib_build.sh

自定義 WGPUSurfaceView

安卓視圖組件 SurfaceView 提供了一個可嵌入在視圖層級結構中的專用于繪制的視圖。它負責繪制表面(Surface)在屏幕上的正確位置,還控制著繪制表面的像素格式及分辨率大小。
SurfaceView 持有的繪制表面是獨立于 App 窗口的,可以在單獨的線程中進行繪制而不占用主線程資源。所以使用 SurfaceView 可以實現(xiàn)復雜而高效的渲染(比如,游戲、視頻播放、相機預覽等),且不會阻塞用戶交互(觸摸、鍵盤輸入等)的響應。

安卓系統(tǒng)上的繪制表面是縱深排序(Z-Ordered)的,它默認處在 App 窗口的后面, SurfaceView 通過在 App 窗口上面設置透明區(qū)域來展示處在后面的繪制表面。
如果想將繪制表面放置到窗口的最上層,可以通過 setZOrderOnTop() 函數(shù)來實現(xiàn):

mySurfaceView.setZOrderOnTop(true)

這里有必要多解釋一句:wgpu 里的 Surface 對象雖然最終指向的就是 SurfaceView 持有的繪制表面,但它是一個經(jīng)過統(tǒng)一封裝的結構體,所以并不是同一個對象:

pub struct Surface {
    pub(crate) presentation: Option<Presentation>,
    #[cfg(vulkan)]
    pub vulkan: Option<HalSurface<hal::api::Vulkan>>,
    #[cfg(metal)]
    pub metal: Option<HalSurface<hal::api::Metal>>,
    #[cfg(dx12)]
    pub dx12: Option<HalSurface<hal::api::Dx12>>,
    #[cfg(dx11)]
    pub dx11: Option<HalSurface<hal::api::Dx11>>,
    #[cfg(gl)]
    pub gl: Option<HalSurface<hal::api::Gles>>,
}

窗口的視圖層級結構決定了與繪制表面的正確合成,也就是說,繪制表面的展示會受到視圖層級關系的影響,在 SurfaceView 所處層級之上的視圖會覆蓋(遮擋)在合成后的繪制表面之上。
需要注意的是,如果覆蓋內(nèi)容存在透明度,則每次繪制表面渲染完成后,都會進行一次完整的 alpha 混合合成,這會對性能產(chǎn)生不利影響。

我們只能通過 SurfaceHolder 接口來訪問繪制表面。當 SurfaceView 在窗口中可見時,繪制表面就會被創(chuàng)建,而不可見時(比如,App 被切換到后臺運行)繪制表面會被銷毀,所以需要實現(xiàn) SurfaceHolder 的回調(diào)接口 surfaceCreatedsurfaceDestroyed 來發(fā)現(xiàn)繪制表面的創(chuàng)建和銷毀。
下邊的代碼實現(xiàn)了一個繼承自 SurfaceViewWGPUSurfaceView

// 為當前類實現(xiàn) SurfaceHolder 的回調(diào)接口
class WGPUSurfaceView : SurfaceView, SurfaceHolder.Callback2 {
    private var rustBrige = RustBridge()
    // Rust 對象的指針
    private var wgpuObj: Long = Long.MAX_VALUE
    private var idx: Int = 0

    //...

    init {
        // 將當前類設置為 SurfaceHolder 的回調(diào)接口代理
        holder.addCallback(this)
    }

    // 繪制表面被創(chuàng)建后,創(chuàng)建/重新創(chuàng)建 wgpu 對象
    override fun surfaceCreated(holder: SurfaceHolder) {
        holder.let { h ->
            wgpuObj = rustBrige.createWgpuCanvas(h.surface, this.idx)
            // SurfaceView 默認不會自動開始繪制,setWillNotDraw(false) 用于通知 App 已經(jīng)準備好開始繪制了。
            setWillNotDraw(false)
        }
    }

    // 繪制表面被銷毀后,也銷毀 wgpu 對象
    override fun surfaceDestroyed(holder: SurfaceHolder) {
        if (wgpuObj != Long.MAX_VALUE) {
            rustBrige.dropWgpuCanvas(wgpuObj)
            wgpuObj = Long.MAX_VALUE
        }
    }

    override fun draw(canvas: Canvas?) {
        super.draw(canvas)
        // 考慮到邊界情況,這個條件判斷不能省略
        if (wgpuObj == Long.MAX_VALUE) {
            return
        }
        rustBrige.enterFrame(wgpuObj)
        // invalidate() 函數(shù)通知通知 App,在下一個 UI 刷新周期重新調(diào)用 draw() 函數(shù) 
        invalidate()
    }
}

App 中加載 WGPUSurfaceView

現(xiàn)在可以在 Activity 或 Fragment(此處僅指安卓 Fragment,與著色器里的片元無關)里加載 WGPUSurfaceView 實例了,通過 XML 或者 Java/Kotlin 代碼來加載很常見,下面我們來看看在安卓上的新一代 UI 開發(fā)框架 Jetpack Compose 中如何加載:

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        setContent {
            MyApplicationTheme {
                Surface(
                    modifier = Modifier.fillMaxSize(),
                    color = colorResource(id = R.color.white)
                ) {
                    SurfaceCard()
                }
            }
        }
    }
}

@Composable
fun SurfaceCard() {
    val screenWidth = LocalConfiguration.current.screenWidthDp.dp
    Column(modifier = Modifier.fillMaxSize()) {
        Row(
            verticalAlignment = Alignment.CenterVertically,
            horizontalArrangement = Arrangement.Center
        ) {
            Text(text = "wgpu on Android", fontSize = 20.sp, fontWeight = FontWeight.Bold)
        }
        // ...

        // 通過 AndroidView 容器來加載我們的 WGPUSurfaceView
        AndroidView(
            factory = { ctx ->
                WGPUSurfaceView(context = ctx)
            },
            modifier = Modifier
                .fillMaxWidth()
                .height(screenWidth),
        )
    }
}

基于以上代碼,我寫了一個叫 wgpu-in-app 的示例程序,運行效果如下:

wgpu in Android App

查看 wgpu-in-app 完整項目源碼!

更多 wgpu 學習教程

最后編輯于
?著作權歸作者所有,轉載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

相關閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容