ブログ
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 など)が含まれます。アーキテクチャはハードウェアがバイナリマシンコードをどのように解釈するか(命令セットアーキテクチャ)を決定し、オペレーティングシステムはアプリケーションがファイルシステムにアクセスしたり、プロセスやスレッドを作成したりする方法を決定します。いわゆるプラットフォームとは、これらの異なる要素の組み合わせです。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 シェーダー、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 を作成し、シェーダーをコンパイルするなど
  2. 各フレームで実行(render):Device を利用して CommandEncoder を作成し、命令をエンコードした後 Queue に提出する
  3. その後、使用するウィンドウフレームワークに応じて、毎回再描画するときに render を呼び出す必要があります。例えば winit の EventLoopWindowEvent::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 にどのように渡すのか

まず最初の質問に答えます。CAMetalLayerMTKView を作成することで、底層の 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()
    }
}

次に 2 つ目の質問に答えます。Swift は LLVM をバックエンドとして使用し、バイナリオブジェクトにコンパイルした後、リンカーを通じて静的ライブラリをリンクし、最終的に実行可能なバイナリファイルとして結合します。したがって、CAMetalLayer を SwiftUI から Rust に渡す大まかな手順は次の 2 つです:

  1. Rust を static library にコンパイルし、C-ABI ベースのインターフェース関数を公開する
  2. iOS プロジェクトでこれらの公開された関数シンボルを宣言し、最終的なリンク時にシンボルを static library に再配置する

現代の言語とコンパイルツールのサポートにより、これらの 2 つのステップはどちらも非常に少ない労力で済みます。

実行可能プログラムのコンパイル、リンク

簡単に言えば、コードテキストは最終的なバイナリファイルにコンパイルされる前に、ある粒度で最初に複数のバイナリファイルに個別にコンパイルされます。例えば、あるモジュールが使用するサードパーティ依存関係内の関数名や変数名は、最初に「埋め込み保留領域」に配置されます。複数のバイナリは、リンク時に各自の埋め込み保留領域の欠落に基づいて、他のバイナリファイル内の対応する宣言されたシンボルを見つけ、埋め込みと統合を行います。

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

これらの 2 つの関数のシグネチャが、Rust で公開された関数のシグネチャ(関数名、パラメータタイプ、戻り値タイプ)と対応していることがわかります。さらに Xcode プロジェクト設定では、1 つはヘッダーファイルをインポートする必要があり、2 つ目はリンク段階で使用する 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 の機能を直接利用して、2 つの関数を 1 つの構造体にラップし、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 にコピーし、2 つの整数 ptr と len に変換して、それぞれ文字列のアドレスと長さを表します

最後に Web プロジェクトで WebAssembly を初期化し、私たちが書いた Rust API を呼び出すことができます。サンプルプロジェクトでは、すべてが順調であれば、ブラウザの Canvas 内にグラフィックが表示されるはずです。

ブラウザでの効果
ブラウザでの効果

おわりに

本文の実践を通じて、Windows / macOS、iOS および Web において wgpu のクロスプラットフォームレンダリングを成功させました(Android の方法については jinleili の記事を参照してください)。このクロスプラットフォーム機能は、wgpu が異なるレンダリングバックエンドと表示平面の抽象化とサポートに依存しており、これにより 1 セットのコアレンダリングロジックを書き、異なるプラットフォームに対して「少量」の適応作業を行うだけで済むようになっています。

実際のアプリケーションでは、このクロスプラットフォームソリューションは開発者に顕著な利点をもたらします。一方では、メンテナンスコストを大幅に削減し、各プラットフォーム向けに別々のレンダリングエンジンを開発する作業量を減らします。他方では、Rust エコシステムの成熟と WebGPU 標準の進展に伴い、wgpu ベースのこのクロスプラットフォームソリューションはより完全で信頼性の高いものになるでしょう。

この記事とオープンソースのコードが皆さんの助けになれば幸いです!

シェア