博客
Cover Image - Rust wgpu 跨平台开发实践
技术

Rust wgpu 跨平台开发实践

前言

教程的完整代码可见:https://github.com/CPunisher/wgpu-cross-tutorial

本文将重心放在“跨平台”的实现上,而不是在 wgpu 以及渲染的原理上。想要更深入的学习渲染的原理,请参考 jinleili 的教程。本文的核心原理也是参考该教程中的“集成和调试”部分。因此,本文可以视作对该教程中“跨平台”集成部分扩展和补充。

既然说要跨平台,那么平台具体是什么呢?在普通的 CPU 程序的编译中,我们会将目标平台使用目标三元组(target triple)来表示,他包括 CPU 的体系结构(x86、Arm、Risc-V 等),操作系统(Windows、Linux、MacOS 等)。体系结构决定了硬件是如何解释二进制机器码的(Instruction Set Architecture),而操作系统决定了应用程序要如何访问文件系统、创建进程和线程等。所谓平台就是这些不同要素的排列组合。在 GPU 图形编程中,也有构成不同平台的要素:

  1. GPU 硬件的 ISA。
    然而相比于 CPU 的体系结构,我们更加耳熟能详的其实是渲染后端,例如 OpenGL,Vulkan,Metal,DirectX。他们封装了底层图形硬件的抽象,能将各自的着色器语言指令和图形 API 指令编译到不同的硬件指令上。
  2. 展示平面(Surface)。
    展示平面提供了实际显示区域、像素操作接口、基本绘图功能等软件接口。他一般是由具体的软件框架提供,例如 Apple 的 CoreAnimationLayer,Qt 框架的 QSurface 和 Web Canvas 中的 WebGL 或者 2D Canvas。

因此,想要将同一份代码在不同的平台上运行,关键在于对这些有差异的部分进行抽象,然后根据不同的平台选择不同的组件。所幸的是,wgpu 几乎帮我们解决了所有兼容问题:

  1. wgpu 直接提供了对于不同渲染后端的选项,并且抽象出了 DeviceQueue
    1. Device: 渲染资源的供应商,所有渲染需要的 Buffer、Pipeline、GPU 指令的 Encoder 等资源都需要通过这个结构体分配。
    2. Queue: 向 GPU 发送渲染或者计算指令的队列,所有指令通过 Encoder 编码到 Buffer 之后,通过 Queue::submit 正式向 GPU 发出渲染或者计算请求。
  2. 不同渲染平台所支持的着色器语言也不相同,wgpu 内置了 Naga 编译器,能够将同一份 WGSL 语言编译到其他着色器语言上,例如 DirectX 的 HLSL,Metal 的 MSL,OpenGL 的 GLSL 和 Vulkan 的 SPIR-V。
  3. wgpu 也直接提供了对于不同 Surface 的支持,其中就包括特定于 Apple 的 CoreAnimationLayer 和其他系统上或者 Display Server 上的适配。
WGPU
WGPU

综上所述,借助 wgpu 的力量,想要在不同平台上渲染统一的图形,只需保持整体步骤(WGSL shader、Pipeline、Encoder 的指令)一致,然后调整:

  1. 设置不同的渲染后端
  2. 根据所使用的图形框架,设置不同的 Surface
  3. 可能需要进行一些小的配置上的调整,例如 pixel format

通用流程

wgpu 的初始化流程也可以参考 jinleili 的教程 中的“依赖与窗口”和“展示平面”章节,基本上每个平台都是一样的样板代码。为了适配多个平台,我对代码做了少许改编,大致流程如下:

pub mod renderer;

pub struct InitWgpuOptions {
    pub target: wgpu::SurfaceTargetUnsafe,
    pub width: u32,
    pub height: u32,
}

pub struct WgpuContext {
    pub surface: wgpu::Surface<'static>,
    pub device: wgpu::Device,
    pub queue: wgpu::Queue,
    pub config: wgpu::SurfaceConfiguration,
}

pub async fn init_wgpu(options: InitWgpuOptions) -> WgpuContext {
    let instance = wgpu::Instance::new(&wgpu::InstanceDescriptor {
        // 1. Select graphics backends and create `Instance`. We enable all backends here.
        backends: wgpu::Backends::all(),
        ..Default::default()
    });

    // 2. Create wgpu `Surface` with instance
    let surface = unsafe { instance.create_surface_unsafe(options.target).unwrap() };

    // 3. Request `Adapter`` from instance
    let adapter = instance
        .request_adapter(&wgpu::RequestAdapterOptions {
            power_preference: wgpu::PowerPreference::default(),
            compatible_surface: Some(&surface),
            force_fallback_adapter: false,
        })
        .await
        .unwrap();

    // 4. Request `Device` and `Queue` from adapter
    let (device, queue) = adapter
        .request_device(
            &wgpu::DeviceDescriptor {
                required_features: wgpu::Features::empty(),
                required_limits: wgpu::Limits::default(),
                label: None,
                memory_hints: wgpu::MemoryHints::Performance,
            },
            None,
        )
        .await
        .unwrap();

    // 5. Create `SurfaceConfig` to config the pixel format, width and height, and alpha mode etc.
    let caps = surface.get_capabilities(&adapter);
    let config = wgpu::SurfaceConfiguration {
        usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
        format: caps.formats[0],
        width: options.width,
        height: options.height,
        present_mode: wgpu::PresentMode::Fifo,
        alpha_mode: caps.alpha_modes[0],
        view_formats: vec![],
        desired_maximum_frame_latency: 2,
    };
    surface.configure(&device, &config);

    WgpuContext {
        surface,
        device,
        queue,
        config,
    }
}

于是我们得到了所有图形渲染需要的元素。之后可以随意自己想要渲染的内容,步骤大致如下,jinleili 的教程

  1. 初始化渲染内容(init):利用 Device 创建渲染所需的 Buffer、Pipeline,编译 shader 等
  2. 每一帧都执行(render):利用 Device 创建 CommandEncoder,对指令编码后提交到 Queue
  3. 之后还需要根据所使用的窗口框架,在每次重绘的时候调用 render。例如 winit 的 EventLoop 中接受到 WindowEvent::RedrawRequested 事件时;iOS 的话在组件的 draw 方法中调用;Web Canvas 的话可以手动在 requestAnimationFrame 中不断调用。

在 Windows/MacOS 桌面端渲染

桌面端使用 winit 作为跨平台的窗口管理和时间循环库。wgpu 原生支持从 winit 的 window 实例中创建 Surface。整体步骤非常直接和简单,参考 示例代码jinleili 的教程 即可。

在 iOS 端渲染

虽然 winit 也支持 iOS 平台上相关的 API,但是我们一般更希望在 App 的局部范围内渲染图形。因此我们并不直接使用 winit 接管整个 App 的窗口。我们将直接使用 wgpu 和 SwiftUI/UIKit 进行交互。

在 iOS 上,图形后端实际上选择的是 Metal,Surface 选择 CAMetalLayer。于是就有 2 个问题:

  1. CAMetalLayer 从哪里来
  2. CAMetalLayer 怎么从 SwiftUI 传到 Rust

首先回答第一个问题,CAMetalLayer 可以通过创建 MTKView 拿到底层的 layer。MTKView 是 UIKit 的组件,并不能在 SwiftUI 中直接使用,所幸 Apple 提供了 UIViewRepresentable 允许我们将 UIKit 组件封装为 SwiftUI 组件,并利用 CoordinatorMTKViewDelegate 实现 UIKit 的事件委托和数据传递:

import SwiftUI
import MetalKit

struct WgpuLayerView: UIViewRepresentable {
    typealias UIViewType = MTKView
    
    func makeCoordinator() -> Coordinator {
        let coordinator = Coordinator()
        return coordinator
    }
    
    func makeUIView(context: Context) -> MTKView {
        let view = MTKView()
        view.delegate = context.coordinator
        view.device = MTLCreateSystemDefaultDevice()
        view.preferredFramesPerSecond = 60
        view.enableSetNeedsDisplay = false
        return view
    }
    
    func updateUIView(_ uiView: MTKView, context: Context) {}
    
    class Coordinator: NSObject, MTKViewDelegate {
        func mtkView(_ view: MTKView, drawableSizeWillChange size: CGSize) {}
        func draw(in view: MTKView) {
	        // CAMetalLayer: view.layer
        }
    }
}

然后,我们就可以用熟悉的 SwiftUI 的方式创建该组件:

struct ContentView: View {
    var body: some View {
        VStack {
            WgpuLayerView()
                .frame(width: 400, height: 400)
        }
        .padding()
    }
}

接下来回答第二个问题。Swift 使用 LLVM 作为后端,在编译到二进制对象之后,能够通过链接器链接静态库,最终组合为可执行的二进制文件。因此,将 CAMetalLayer 从 SwiftUI 传递到 Rust 大致分为两步:

  1. 将 Rust 编译到 static library,并且暴露基于 C-ABI 的接口函数
  2. 在 iOS 项目中声明这些暴露的函数符号,并在最终链接时将符号重定位到 static library 上

在现代语言和编译工具的加持下,这两个步骤都不需要非常多的人力。

可执行程序的编译、链接

简单来说,代码文本在编译到最终的二进制文件之前,会以某个粒度先分别编译为多个二进制文件。例如某个模块所使用的第三方依赖中的函数名、变量名会被首先放到一个“待填充区”。多个二进制在链接时根据各自待填充区的空缺,找到其他二进制文件中对应声明的符号后,进行填充和整合。

要想将 Rust 程序编译到 static library,只需要在 Cargo.toml 中声明:

[lib]
crate-type = ["staticlib"]

接下来我们声明兼容 C-ABI 的接口,在参数传递上只使用指针和整数等简单的数据类型。

在初始化函数中,通过 Box::into_raw 的方式,将堆上的 Rust 数据结构暴露给 Swift。

render 函数中,我们希望接受暴露给 Swift 的指针,重新取得 Rust 数据结构的引用,然后调用我们封装好的渲染函数。

#[repr(transparent)]
pub struct WgpuWrapper(*mut c_void);

#[unsafe(no_mangle)]
pub fn init_wgpu(metal_layer: *mut c_void, width: u32, height: u32) -> WgpuWrapper {
    let app = pollster::block_on(app::App::init(metal_layer, width, height));
    WgpuWrapper(Box::into_raw(Box::new(app)).cast())
}

#[unsafe(no_mangle)]
pub fn render(wrapper: WgpuWrapper) {
    let app = unsafe { &*(wrapper.0 as *mut app::App) };
    app.render();
}

no_mangle 的作用

现代编译器为了解决编译链接过程中符号解析可能会引入的各种问题,会将代码中所定义的符号进行重命名。在暴露二进制 API 的时候,我们希望能够通过我们在代码中所写的名字来调用这些 API,因此使用 no_mangle 来抑制编译器的重命名行为。

通过 cargo 命令得到编译产物 libswift_binding.a 并复制到 Xcode 工程下。其中 --target aarch64-apple-ios-sim 指定了目标程序平台为 arm 架构的 iOS 模拟器。对于真机,应该进行相应的修改。

cargo build --release -p swift-binding --target aarch64-apple-ios-sim
cp target/aarch64-apple-ios-sim/release/libswift_binding.a WgpuCross/WgpuCross/Generated/

接下来是第 2 步。首先在 Xcode 工程中声明头文件:

#ifndef libswift_binding_h
#define libswift_binding_h

void *init_wgpu(void* metal_layer, int width, int height);
void render(const void* wrapper);

#endif

可以看到这两个函数的签名和 Rust 中暴露的函数签名(函数名、参数类型、返回值类型)是相对应的。此外在 Xcode 项目配置中,我们一是需要引入头文件,二是需要指定 static library 的路径,供链接阶段使用。

  • 引入头文件: 修改 Build SettingsSwift Compiler - GeneralObjective-C Bridging Header,填写头文件路径,例如: $(PROJECT_DIR)/WgpuCross/BridgingHeader.h

  • 指定 static library 路径:修改 Build PhasesLink Binary With Libraries,添加我们复制到 Xcode 工程中的 Rust 编译产物 libswift_binding.a

然后我们就可以在 Swift 代码中调用头文件中声明的函数了。我们需要在 Coordinatordraw 方法中初始化 wgpu 和每一帧调用 Rust 的渲染函数:

class Coordinator: NSObject, MTKViewDelegate {
    var wrapper: UnsafeMutableRawPointer?
    
    func mtkView(_ view: MTKView, drawableSizeWillChange size: CGSize) {}
    
    func draw(in view: MTKView) {
        if wrapper == nil {
            let metalLayer = Unmanaged.passUnretained(view.layer).toOpaque()
            wrapper = init_wgpu(metalLayer, Int32(view.frame.width), Int32(view.frame.height))
        }
        if let wrapper = wrapper {
            render(wrapper)
        }
    }
}

在示例工程中,如果一切顺利的话,你将在模拟器上看到一个 400x400 大小的 wgpu 渲染的蓝色矩形:

iOS 上的效果
iOS 上的效果

在 Web 浏览器中渲染

实现 Web 的渲染和实现 iOS 渲染非常相似。在 Web 环境中的图形后端由浏览器调用系统的图形后端实现,Surface 从 Canvas 元素派生。相比于 iOS 端,Rust(WebAssembly)和 JavaScript 之间的打包和通信被 wasm-bindgen 一站式解决了,并且 API 也非常简单易用。

WebAssembly 是什么

WebAssembly 是 W3C 规定的除 JavaScript 外另一个能够在浏览器内执行的语言,主要的目的是为了提供接近 Native 运行速度,以及实现让其他编程语言在浏览器中允许的可能性。

我们的设计思路和 iOS 端相同,利用 WebAssembly 向 JS 暴露初始化函数和渲染函数。我们可以直接利用 wasm-bindgen 的能力将两个函数封装到一个结构体中,并向 JS 导出一个 Class。

#[wasm_bindgen]
pub struct WgpuWrapper {
    app: App,
}

#[wasm_bindgen]
impl WgpuWrapper {
    #[wasm_bindgen(constructor)]
    pub async fn new(canvas_id: &str) -> Self {
        let window = web_sys::window().expect("Cannot get window");
        let document = window.document().expect("No document on window");
        let canvas: web_sys::HtmlCanvasElement = document
            .get_element_by_id(canvas_id)
            .and_then(|element| element.dyn_into().ok())
            .expect("Cannot get canvas by id");

        let width = canvas.width();
        let height = canvas.height();
        let app = App::init(canvas, width, height).await;
        Self { app }
    }

    pub fn render(&self) {
        self.app.render();
    }
}

这里我们的设计是希望 JS 在初始化的时候传入目标 Canvas 的 id,在 Rust 侧使用 web-sys 调用 Document API 拿到 Canvas 元素。接下来是封装的 App 结构体的实现,下面只显示了有所不同的初始化函数,:

pub(crate) struct App {
    _canvas: HtmlCanvasElement,
    context: WgpuContext,
    renderer: Renderer,
}

impl App {
    pub async fn init(canvas: HtmlCanvasElement, width: u32, height: u32) -> Self {
        let context = wgpu_cross::init_wgpu(wgpu_cross::InitWgpuOptions {
            // Create handles from canvas element
            target: wgpu::SurfaceTargetUnsafe::RawHandle {
                raw_display_handle: {
                    let handle = WebDisplayHandle::new();
                    RawDisplayHandle::Web(handle)
                },
                raw_window_handle: {
                    let obj: NonNull<core::ffi::c_void> = NonNull::from(&canvas).cast();
                    let handle = WebCanvasWindowHandle::new(obj);
                    wgpu::rwh::RawWindowHandle::WebCanvas(handle)
                },
            },
            width,
            height,
        })
        .await;
        let renderer = Renderer::init(&context);

        Self {
            _canvas: canvas,
            context,
            renderer,
        }
    }
}

和 iOS 端编译不同的是,编译到 wasm 要求在 Cargo.toml 中设置编译的产物类型为动态链接库:

[lib]
crate-type = ["staticlib"]

然后使用 wasm-pack 将 Rust 代码编译,并自动生成一些胶水代码,例如:

export class WgpuWrapper {
  free(): void;
  constructor(canvas_id: string);
  render(): void;
}

为什么需要胶水代码

WebAssembly 中只规定了非常简单的数据类型(整数、浮点数、用于 SIMD 的向量),以及类似汇编的基本计算指令、分支指令等等。为了能够和外部世界(宿主环境)交互,WebAssembly 同时也规定了 import 和 export。因此,胶水代码大致有 2 个作用:

  1. 向 WebAssembly 提供一些指令无法实现的 API,例如浏览器中 API
  2. 进行数据的序列化和反序列化。例如 Wasm 中不能直接支持 String,胶水代码会将 String 拷贝到 Wasm 的 Memory 中,并转化为两个整数 ptr 和 len 分别表示字符串的地址和长度

最后就能在 Web 工程中初始化 WebAssembly 并调用我们编写的 Rust API 了。在示例工程中,如果一切顺利的话,你将在浏览器中看到 Canvas 中的图形。

浏览器中的效果
浏览器中的效果

结语

通过本文的实践,我们成功地在 Windows / macOS、iOS 以及 Web 中实现了 wgpu 的跨平台渲染(Android 的方法也请参考 jinleili 的文章)。这种跨平台能力得益于 wgpu 对不同渲染后端和展示平面的抽象与支持,使我们能够编写一套核心渲染逻辑,同时只需针对不同平台做”少量“适配工作。

在实际应用中,这种跨平台方案为开发者提供了显著的优势。一方面,它大幅度降低了维护成本,减少了为每个平台单独开发渲染引擎的工作量;另一方面,随着 Rust 生态的不断成熟和 WebGPU 标准的推进,这种基于 wgpu 的跨平台方案也将变得更加完善和可靠。

希望本文和开源的代码能给大家带来一些帮助!

分享