11 人が閲覧(直近 30 日) Rust

wgpuとは?Rustで三角形描画とGPU計算を動かす手順【wgpu 30対応】

wgpuとは?Rustで三角形描画とGPU計算を動かす手順【wgpu 30対応】

wgpuは、WebGPUのAPIを土台にしたRust製のGPUライブラリです。Windows・macOS・Linux・Android・iOSのネイティブ環境と、WebAssembly経由のブラウザに対応し、内部ではVulkan・Metal・DirectX 12・OpenGL・WebGPUを呼び分けます。この記事では、wgpuが内部で何をしているのか、GPU計算(Compute)とウィンドウへの三角形描画の完全なコード、nagaによるシェーダーの検証と変換、バックエンドの選ばれ方、28〜30系の破壊的変更の直し方までを扱います。掲載したコードはすべて、2026年10月20日に wgpu 30.0.1・winit 0.30.13・rustc 1.99.1(macOS 26.6.2、AMD Radeon Pro 5500M/Metal)でビルドして実行し、出力をそのまま載せています。

まとめ:wgpu(Rust)の要点

  • 正体:WebGPU APIをRustの型で書けるようにしたGPUライブラリ。内部で各OSのネイティブAPIを呼び分け、WGSLシェーダーはnagaがSPIR-V・MSL・HLSL・GLSLへ変換する。FirefoxのWebGPU実装の中核でもある。
  • 最新版:wgpu 30.0.1(2026年8月)。メジャーはほぼ3か月ごとに上がり、毎回APIが変わる。Cargo.toml でメジャーを固定し、周辺クレートと同じメジャーにそろえる。
  • 古いサンプルが動かない主因:InstanceDescriptor::default() の削除(v29)、frame.present() から queue.present(frame) への変更(v30)、get_current_texture の戻り値の列挙型化(v29)。
  • GPU計算だけならウィンドウは不要:Instance→Adapter→Device/Queueまで作れば、Compute Shaderを実行して結果をCPUへ読み戻せる。
  • macOSの落とし穴:ウィンドウが隠れると Occluded が返る。そのまま再描画を要求し続けると、手元では6秒で20万回を超える空回りになった。
  • ブラウザ:WebGPU対応ブラウザで動くが、掲載したネイティブ向けコードは移植が要る(pollster での同期待ちと device.poll による完了待ちがブラウザでは使えない)。WebGL2へのフォールバックには webgl featureが要り、WebGL2ではstorage bufferを使うCompute Shaderが動かない。

wgpuとは:WebGPU APIを土台にしたRustのGPUライブラリ

公式リポジトリのREADMEは、wgpuをクロスプラットフォームで安全な純Rust製グラフィックスAPIと説明しています。ネイティブではVulkan・Metal・D3D12・OpenGLの上で動き、wasmではWebGL2とWebGPUの上で動きます。APIはWebGPU標準に基づきますが、ブラウザの機能を呼ぶラッパーではなく、それ自体がネイティブのRustライブラリです。同じREADMEには、FirefoxのほかServoとDenoでもWebGPU統合の中核になっていると書かれています。

wgpuが実際にやっていること:API呼び分けとシェーダー変換

GPUを動かすには、OSごとに違うAPIを呼ぶ必要があります。WindowsならDirectX 12かVulkan、macOSとiOSならMetal、LinuxとAndroidならVulkanかOpenGL ESです。wgpuは、アプリが呼ぶ1つのAPI(バッファやテクスチャの確保、パイプラインの作成、コマンドの送信)を、実行環境のネイティブAPIの呼び出しに置き換えます。

シェーダーも同様です。ネイティブAPIはそれぞれ受け付けるシェーダー形式が違うため、wgpuはWGSLで書かれたシェーダーを、同梱のコンパイラnagaでVulkan向けのSPIR-V、Metal向けのMSL、DirectX向けのHLSL、OpenGL向けのGLSLへ変換してから渡します。この変換は後の「nagaによるWGSLの検証とシェーダー変換」の章で、実際のコマンド出力で確認します。

WebGPU・wgpu・wgpu-native・wgpu-pyの関係

名前が似たものが多いので、役割を分けておきます。

名前 何か 使う言語
WebGPU W3Cの標準仕様(ブラウザのAPI) JavaScript/WGSL
wgpu WebGPUに基づくRustクレート Rust
wgpu-native wgpuのC言語バインディング C/C++ほか
wgpu-py wgpu-nativeを包むPythonパッケージ Python

C言語や他言語から使う場合は、READMEが案内するwgpu-nativeを経由します。Pythonのwgpu-py 0.32.0が読み込んでいたのはwgpu-native 29.0.1.1で、Rustのwgpu 30より1つ前のメジャーに当たります。

JavaScriptのWebGPUとRustのwgpuの対応

WebGPUの日本語情報はJavaScript前提のものが多く、メソッド名がcamelCaseです。Rustのwgpuでは同じ操作をsnake_caseで呼び、いくつかの操作は書き方そのものが違います。

操作 JavaScript(WebGPU) Rust(wgpu 30)
入口 navigator.gpu wgpu::Instance::new(...)
アダプタ取得 requestAdapter() instance.request_adapter(..).await
デバイス取得 adapter.requestDevice() adapter.request_device(..).await
描画先 canvas.getContext("webgpu") instance.create_surface(window)
パスの終了 pass.end() RenderPassのdrop(スコープを抜ける)
画面へ表示 自動(ブラウザの描画更新時) queue.present(frame)

JavaScriptではキャンバスのテクスチャが自動で表示されますが、Rustでは表示を明示的に呼びます。この呼び方はv30で frame.present() から queue.present(frame) に変わったので、2026年7月より前のサンプルはここでビルドが止まります。

wgpuとOpenGL・Vulkanの違いと選ぶ基準

wgpuは、OpenGLやVulkanと同じ層の選択肢ではなく、それらの上に載る抽象化レイヤーです。比べると次のようになります。

項目 OpenGL Vulkan wgpu
位置づけ ネイティブAPI ネイティブAPI ネイティブAPIの上の層
シェーダー GLSL SPIR-V WGSL(変換して各APIへ)
ブラウザ WebGL2 不可 WebGPU/WebGL2
リソース誤用の検出 ドライバ任せ 検証レイヤー任せ wgpu自身が検証

wgpuを選ぶべきなのは、1つのコードでデスクトップ・モバイル・ブラウザをまとめて扱いたいとき、そしてCUDAに依存しないGPU計算が欲しいときです。逆に、特定のネイティブAPIでしか使えない機能を突き詰めたい場合は向きません。wgpuの機能はWebGPUの範囲が基本で、それを超える機能は Features で個別に要求し、対応するバックエンドでしか動きません。たとえばv30で入ったWGSLの16bit整数(Features::SHADER_I16)は、CHANGELOGによればVulkan・Metal・DX12(SM 6.2以上)のみの対応です。OpenGL自体の現状はOpenGLとは?最新4.6の現状とWindows 11での入手・確認方法、Vulkanとの違いで扱っています。

プロジェクトの作成:Cargo.tomlと版の固定

検証に使った依存関係です。ウィンドウ管理にwinit、async な初期化をmain関数から待つためにpollster、バッファとバイト列の変換にbytemuckを使います。

[dependencies]
wgpu = "30"
winit = "0.30"
pollster = "1"
bytemuck = "1"
env_logger = "0.11"

wgpu 30のMSRV(必要な最低Rustバージョン)は1.87です(README のMSRV policy)。既定のfeatureには vulkan・metal・dx12・gles・wgsl・webgpu が入っていますが、webgl は入っていません。GLSLやSPIR-Vのシェーダーをそのまま読み込みたい場合も、glsl・spirv のfeatureを足します。

周辺クレートとwgpuのメジャーバージョンの整合

wgpuのリリースは、26.0.0(2025年7月)、27.0.0(2025年10月)、28.0.0(2025年12月)、29.0.0(2026年3月)、30.0.0(2026年7月)とほぼ3か月ごとです。eguiやBevyと組み合わせるときは、そのクレートが依存するwgpuのメジャーに自分の Cargo.toml を合わせます。メジャーがずれると2つのwgpuが同時にビルドされ、片方の wgpu::Device をもう片方へ渡せずコンパイルエラーになります。2026年10月20日にcrates.ioで確認した依存は次のとおりです。

クレート 最新版 依存するwgpu
bevy_render(Bevy) 0.20.0 ^30
egui-wgpu 0.37.0 ^30.0
vello(2Dベクター描画) 0.11.0 ^30.0.0
glyphon(テキスト描画) 0.12.0 ^30.0.0
cubecl-wgpu(Burnが使う) 0.11.0 ^30.0.0
iced_wgpu(iced) 0.14.0 ^27.0

icedだけは27系に依存しているので、icedと一緒に使う場合はwgpuを27に下げます。この記事のコードは30用なので、そのままでは動きません。BevyのECSはBevy ECSとは?Rust製ゲームエンジンの仕組みと書き方を0.19で実行検証、BurnのGPUバックエンドはBurn 0.21とは?Rust製ディープラーニングフレームワークのバックエンド選定と実装手順で扱っています。

Compute Shaderによるウィンドウ不要のGPU計算

まずは表示を伴わないGPU計算です。1000個の f32 をGPUに送り、Compute Shaderで2倍にしてCPUへ読み戻します。ファイルは src/bin/compute.rs とし、cargo run --release --bin compute で実行します。

use wgpu::util::DeviceExt;

const SHADER: &str = r#"
@group(0) @binding(0) var<storage, read_write> data: array<f32>;

@compute @workgroup_size(64)
fn main(@builtin(global_invocation_id) id: vec3<u32>) {
    let i = id.x;
    if (i < arrayLength(&data)) {
        data[i] = data[i] * 2.0;
    }
}
"#;

fn main() {
    env_logger::init();
    pollster::block_on(run());
}

async fn run() {
    let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
    let adapter = instance
        .request_adapter(&wgpu::RequestAdapterOptions::default())
        .await
        .expect("GPUアダプタが見つからない");
    let info = adapter.get_info();
    println!("adapter: {} / backend: {:?}", info.name, info.backend);

    let (device, queue) = adapter
        .request_device(&wgpu::DeviceDescriptor::default())
        .await
        .expect("デバイス取得に失敗");

    let input: Vec<f32> = (0..1000).map(|x| x as f32).collect();
    let size = (input.len() * std::mem::size_of::<f32>()) as u64;

    // GPU側で読み書きするバッファ(STORAGE)
    let storage = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
        label: Some("storage"),
        contents: bytemuck::cast_slice(&input),
        usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC,
    });
    // CPUへ読み戻すためのバッファ(MAP_READ)
    let readback = device.create_buffer(&wgpu::BufferDescriptor {
        label: Some("readback"),
        size,
        usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
        mapped_at_creation: false,
    });

    let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
        label: Some("doubler"),
        source: wgpu::ShaderSource::Wgsl(SHADER.into()),
    });
    let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
        label: Some("doubler"),
        layout: None,
        module: &module,
        entry_point: Some("main"),
        compilation_options: Default::default(),
        cache: None,
    });
    let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
        label: None,
        layout: &pipeline.get_bind_group_layout(0),
        entries: &[wgpu::BindGroupEntry {
            binding: 0,
            resource: storage.as_entire_binding(),
        }],
    });

    let mut encoder = device.create_command_encoder(&Default::default());
    {
        let mut pass = encoder.begin_compute_pass(&Default::default());
        pass.set_pipeline(&pipeline);
        pass.set_bind_group(0, &bind_group, &[]);
        // 1000要素 / 64スレッド = 16ワークグループ(切り上げ)
        pass.dispatch_workgroups((input.len() as u32).div_ceil(64), 1, 1);
    }
    encoder.copy_buffer_to_buffer(&storage, 0, &readback, 0, size);
    queue.submit([encoder.finish()]);

    let slice = readback.slice(..);
    slice.map_async(wgpu::MapMode::Read, |r| r.expect("map失敗"));
    device.poll(wgpu::PollType::wait_indefinitely()).unwrap();

    let view = slice.get_mapped_range().expect("mapped range");
    let out: &[f32] = bytemuck::cast_slice(&view);
    println!("先頭5件: {:?}", &out[..5]);
    println!("末尾: {}", out[out.len() - 1]);
    let ok = out.iter().enumerate().all(|(i, &v)| v == i as f32 * 2.0);
    println!("全{}件が2倍か: {}", out.len(), ok);
}

実行結果です。ディスクリートGPUのRadeon Pro 5500MがMetalバックエンドで選ばれ、1000件すべてが元の値の2倍になっています。

adapter: AMD Radeon Pro 5500M / backend: Metal
先頭5件: [0.0, 2.0, 4.0, 6.0, 8.0]
末尾: 1998
全1000件が2倍か: true

押さえるべき点は3つあります。1つ目は InstanceDescriptor::new_without_display_handle() です。v29で InstanceDescriptor の Default 実装が削除されたため、..Default::default() で埋める古い書き方はコンパイルできません。ウィンドウを使わない計算ではこの関数で作ります。2つ目は読み戻しの手順で、GPUが書くバッファ(STORAGE)から MAP_READ 付きのバッファへコピーし、map_async を呼んでから device.poll で完了を待ちます。3つ目は get_mapped_range() の戻り値で、v30からは失敗時にパニックせず Result を返すため、expect などで取り出します。

なお DeviceDescriptor::default() が要求する上限はWebGPUの既定値で、1つのバッファは256MiB、storage bufferの1バインディングは128MiBまでです。手元のRadeon Pro 5500Mは adapter.limits() で約3.2GBのバッファを報告しましたが、既定のままでは256MiBを超えるバッファは作れません。大きなデータを扱うときは required_limits: adapter.limits() のように必要な上限を明示して要求します。

三角形の描画:winit 0.30で動く完全なmain.rs

次はウィンドウへの描画です。Rust側の処理は1つの main.rs にまとめ、シェーダーだけを別ファイルにしています。シェーダーは頂点番号から3頂点の座標と色を決めるので、頂点バッファは使いません。src/shader.wgsl に置きます。

struct VsOut {
    @builtin(position) pos: vec4<f32>,
    @location(0) color: vec3<f32>,
};

@vertex
fn vs_main(@builtin(vertex_index) i: u32) -> VsOut {
    var positions = array<vec2<f32>, 3>(
        vec2<f32>(0.0, 0.5),
        vec2<f32>(-0.5, -0.5),
        vec2<f32>(0.5, -0.5),
    );
    var colors = array<vec3<f32>, 3>(
        vec3<f32>(1.0, 0.0, 0.0),
        vec3<f32>(0.0, 1.0, 0.0),
        vec3<f32>(0.0, 0.0, 1.0),
    );
    var out: VsOut;
    out.pos = vec4<f32>(positions[i], 0.0, 1.0);
    out.color = colors[i];
    return out;
}

@fragment
fn fs_main(in: VsOut) -> @location(0) vec4<f32> {
    return vec4<f32>(in.color, 1.0);
}

src/main.rs です。winit 0.30の ApplicationHandler を実装し、ウィンドウは resumed で作ります。

use std::sync::Arc;
use winit::application::ApplicationHandler;
use winit::event::WindowEvent;
use winit::event_loop::{ActiveEventLoop, EventLoop};
use winit::window::{Window, WindowId};

struct Gpu {
    instance: wgpu::Instance,
    window: Arc<Window>,
    surface: wgpu::Surface<'static>,
    device: wgpu::Device,
    queue: wgpu::Queue,
    config: wgpu::SurfaceConfiguration,
    pipeline: wgpu::RenderPipeline,
}

impl Gpu {
    async fn new(instance: &wgpu::Instance, window: Arc<Window>) -> Self {
        // Arc<Window> を渡すと Surface<'static> になり、借用が絡まない
        let surface = instance.create_surface(window.clone()).unwrap();
        let adapter = instance
            .request_adapter(&wgpu::RequestAdapterOptions {
                compatible_surface: Some(&surface),
                ..Default::default()
            })
            .await
            .unwrap();
        let (device, queue) = adapter
            .request_device(&wgpu::DeviceDescriptor::default())
            .await
            .unwrap();

        let size = window.inner_size();
        let config = surface
            .get_default_config(&adapter, size.width.max(1), size.height.max(1))
            .unwrap();
        surface.configure(&device, &config);

        let shader = device.create_shader_module(wgpu::include_wgsl!("shader.wgsl"));
        let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
            label: Some("triangle"),
            layout: None,
            vertex: wgpu::VertexState {
                module: &shader,
                entry_point: Some("vs_main"),
                buffers: &[],
                compilation_options: Default::default(),
            },
            fragment: Some(wgpu::FragmentState {
                module: &shader,
                entry_point: Some("fs_main"),
                targets: &[Some(config.format.into())],
                compilation_options: Default::default(),
            }),
            primitive: wgpu::PrimitiveState::default(),
            depth_stencil: None,
            multisample: wgpu::MultisampleState::default(),
            multiview_mask: None,
            cache: None,
        });

        Self { instance: instance.clone(), window, surface, device, queue, config, pipeline }
    }

    fn resize(&mut self, width: u32, height: u32) {
        if width > 0 && height > 0 {
            self.config.width = width;
            self.config.height = height;
            self.surface.configure(&self.device, &self.config);
        }
    }

    /// 描画を続けてよいなら true(隠れている間は false)
    fn render(&mut self) -> bool {
        let frame = match self.surface.get_current_texture() {
            wgpu::CurrentSurfaceTexture::Success(f) => f,
            wgpu::CurrentSurfaceTexture::Suboptimal(f) => {
                // SurfaceTexture を持ったまま configure するとパニックするので先に捨てる
                drop(f);
                self.surface.configure(&self.device, &self.config);
                return true;
            }
            wgpu::CurrentSurfaceTexture::Outdated => {
                self.surface.configure(&self.device, &self.config);
                return true;
            }
            wgpu::CurrentSurfaceTexture::Lost => {
                // Surface を作り直してから構成し直す
                self.surface = self.instance.create_surface(self.window.clone()).unwrap();
                self.surface.configure(&self.device, &self.config);
                return true;
            }
            // ウィンドウが隠れている。再描画を止め、Occluded(false) で再開する
            wgpu::CurrentSurfaceTexture::Occluded => return false,
            // Timeout / Validation はこのフレームだけ飛ばす
            _ => return true,
        };
        let view = frame.texture.create_view(&wgpu::TextureViewDescriptor::default());
        let mut encoder = self.device.create_command_encoder(&Default::default());
        {
            let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
                label: Some("main pass"),
                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
                    view: &view,
                    resolve_target: None,
                    depth_slice: None,
                    ops: wgpu::Operations {
                        load: wgpu::LoadOp::Clear(wgpu::Color { r: 0.1, g: 0.2, b: 0.3, a: 1.0 }),
                        store: wgpu::StoreOp::Store,
                    },
                })],
                depth_stencil_attachment: None,
                occlusion_query_set: None,
                timestamp_writes: None,
                multiview_mask: None,
            });
            pass.set_pipeline(&self.pipeline);
            pass.draw(0..3, 0..1);
        } // pass が drop されてレンダーパスが閉じる
        self.queue.submit([encoder.finish()]);
        self.queue.present(frame); // v30〜: frame.present() ではない
        true
    }
}

struct App {
    instance: wgpu::Instance,
    gpu: Option<Gpu>,
}

impl ApplicationHandler for App {
    fn resumed(&mut self, event_loop: &ActiveEventLoop) {
        if self.gpu.is_none() {
            let window = Arc::new(
                event_loop
                    .create_window(Window::default_attributes().with_title("wgpu 30"))
                    .unwrap(),
            );
            self.gpu = Some(pollster::block_on(Gpu::new(&self.instance, window)));
        }
    }

    fn window_event(&mut self, event_loop: &ActiveEventLoop, _id: WindowId, event: WindowEvent) {
        let Some(gpu) = self.gpu.as_mut() else { return };
        match event {
            WindowEvent::CloseRequested => event_loop.exit(),
            WindowEvent::Resized(size) => gpu.resize(size.width, size.height),
            WindowEvent::Occluded(false) => gpu.window.request_redraw(),
            WindowEvent::RedrawRequested => {
                if gpu.render() {
                    gpu.window.request_redraw();
                }
            }
            _ => {}
        }
    }
}

fn main() {
    env_logger::init();
    let event_loop = EventLoop::new().unwrap();
    // v29〜: InstanceDescriptor に Default は無い。winit の display handle を渡す
    let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_with_display_handle(
        Box::new(event_loop.owned_display_handle()),
    ));
    let mut app = App { instance, gpu: None };
    event_loop.run_app(&mut app).unwrap();
}

初期化の流れとdisplay handle

初期化は Instance→Surface→Adapter→Device/Queue の順です。ウィンドウ付きのアプリでは、Instanceを作るときに event_loop.owned_display_handle() を渡します。v29のCHANGELOGは、LinuxのWaylandでEGL(OpenGL ES)を正しく初期化するためにdisplay handleを InstanceDescriptor へ渡すよう求めています。macOSやWindowsでは無くても動きますが、同じコードをLinuxへ持っていくなら最初から渡しておきます。

ウィンドウは Arc<Window> で包み、create_surface(window.clone()) に渡します。こうすると Surface<'static> になり、Surfaceがウィンドウを借用することで起きるライフタイムのエラーを避けられます。request_adapter と request_device はどちらも async で Result を返します(request_adapter はv25で Option から Result に変わりました)。winitのイベントハンドラは async にできないので、pollster::block_on で待ちます。

フレーム取得の分岐とmacOSのOccluded

get_current_texture() はv29から CurrentSurfaceTexture という列挙型を返します。値は Success・Suboptimal・Timeout・Occluded・Outdated・Lost・Validation の7種類です。Lost の扱いは、v29のCHANGELOGの例が「Surfaceの再構成」なのに対し、wgpu 30.0.1のソースのコメントは「create_surface でSurfaceを作り直してから再構成」と案内しており、記述がそろっていません。上のコードは後者に合わせ、Instanceを保持しておいてSurfaceを作り直しています。デバイスごと失われた場合はDeviceと全リソースの作り直しが必要で、その検知には device.set_device_lost_callback を使います。

Suboptimal ではフレームは取れていますが、Surface::configure のドキュメントには、古い SurfaceTexture が残ったまま呼ぶとパニックすると書かれています。そのため取れたフレームを先に drop してから再構成し、そのフレームの描画は飛ばしています。

注意したいのは Occluded です。wgpu-hal 30.0.1のソースを見ると、この値を返すのはmacOSのMetalバックエンドだけで、ウィンドウが完全に隠れているときにフレームの取得を飛ばしています。今回の検証環境ではウィンドウが隠れた状態で起動したため、最初のフレームから Occluded が返りました。そこで Occluded でも毎回 request_redraw() を呼ぶ版を6秒動かしたところ、Occluded が20万回を超え、CPUが空回りしました。上のコードは Occluded のときに再描画の要求を止め、winitの WindowEvent::Occluded(false) を受けたら再開します。winit 0.30.13のドキュメントでは、この Occluded イベントはAndroid・Wayland・Windows・Orbitalで未対応ですが、wgpuが Occluded を返すのもmacOSだけなので、組み合わせとして矛盾はありません。

レンダーパスに明示的な終了呼び出しはありません。RenderPass はエンコーダを借用しており、{ } のスコープを抜けてdropされた時点でパスが閉じます。囲まずに書くと、encoder.finish() が借用エラーになります。RenderPassDescriptor の multiview_mask: None は、v28でマルチビュー(VR向けに複数レイヤーへ同時描画する機能)が作り直されたときに増えた項目で、これが無いと28以降ではビルドできません。

描画結果の確認:オフスクリーン描画とピクセルの読み戻し

ウィンドウが隠れていたため、描画の正しさはオフスクリーン描画で確かめました。同じシェーダーで256×256のテクスチャに描き、バッファへコピーしてピクセルを読みます。要点の部分は次のとおりです(全体は約120行)。

// 1行のバイト数は 256 の倍数にそろえる必要がある(COPY_BYTES_PER_ROW_ALIGNMENT)
let bytes_per_row = (W * 4).div_ceil(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT) * wgpu::COPY_BYTES_PER_ROW_ALIGNMENT;
let readback = device.create_buffer(&wgpu::BufferDescriptor {
    label: Some("readback"),
    size: (bytes_per_row * H) as u64,
    usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
    mapped_at_creation: false,
});

let mut encoder = device.create_command_encoder(&Default::default());
encoder.push_debug_group("draw triangle");
{
    let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
        label: Some("offscreen pass"),
        color_attachments: &[Some(wgpu::RenderPassColorAttachment {
            view: &view,
            resolve_target: None,
            depth_slice: None,
            ops: wgpu::Operations {
                load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
                store: wgpu::StoreOp::Store,
            },
        })],
        depth_stencil_attachment: None,
        occlusion_query_set: None,
        timestamp_writes: None,
        multiview_mask: None,
    });
    pass.set_pipeline(&pipeline);
    pass.draw(0..3, 0..1);
}
encoder.pop_debug_group();
encoder.copy_texture_to_buffer(
    texture.as_image_copy(),
    wgpu::TexelCopyBufferInfo {
        buffer: &readback,
        layout: wgpu::TexelCopyBufferLayout {
            offset: 0,
            bytes_per_row: Some(bytes_per_row),
            rows_per_image: Some(H),
        },
    },
    wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 },
);
queue.submit([encoder.finish()]);

Rgba8UnormSrgb 形式のテクスチャから読み戻したRGBAの値です。RGBはsRGBに符号化されているため、シェーダー内の線形値を単純に255倍した値とは一致しません。3頂点の付近がそれぞれ赤・緑・青、中心が3色の補間、三角形の外側がクリア色の黒になっています。

bytes_per_row=1024 (W*4=1024)
中心(128,128) = [187, 137, 139, 255]
上頂点付近(128,70) = [249, 40, 48, 255]
左下頂点付近(70,188) = [46, 248, 54, 255]
右下頂点付近(186,188) = [46, 48, 249, 255]
隅(5,5) = [0, 0, 0, 255]

テクスチャからバッファへのコピーでは、1行のバイト数を COPY_BYTES_PER_ROW_ALIGNMENT(256)の倍数にする必要があります。幅256ピクセルのRGBAならちょうど1024バイトで端数は出ませんが、幅100ピクセルなら400バイトを512バイトに切り上げ、読むときに行末の余白を飛ばします。スクリーンショット機能や画像の書き出しで画像が斜めにずれる場合は、まずここを疑います。

push_debug_group と pop_debug_group は、コマンドに名前付きの区切りを入れるAPIです。描画結果には影響せず、XcodeやRenderDocなどのGPUデバッガでキャプチャしたときにコマンドの塊が名前で表示されます。同じメソッドは CommandEncoder・RenderPass・ComputePass にあり、1点だけ印を付けるなら insert_debug_marker を使います。

nagaによるWGSLの検証とシェーダー変換

nagaはコマンドラインツール(naga-cli)としても配布されています。cargo install naga-cli で入れると、WGSLの検証と他言語への変換をGPU無しで試せます。上の shader.wgsl を渡した結果です。

$ naga --version
30.0.1
$ naga src/shader.wgsl
Validation successful
$ naga src/shader.wgsl out.metal
$ head -3 out.metal
// language: metal1.0
#include <metal_stdlib>
#include <simd/simd.h>
$ naga src/shader.wgsl out.vert --entry-point vs_main --profile es310
$ head -1 out.vert
#version 310 es

出力先の拡張子で変換先が決まり、.metal ならMSL、.spv ならSPIR-V、.hlsl ならHLSL、.vert ならGLSLになります。wgpuがMetal上で動くときは、内部でこれと同じ変換をしてからMetalに渡しています。

v30では、整数型のシェーダー入出力に @interpolate(flat) を自動で補わなくなりました。@location(0) index: i32 のように書いた古いシェーダーは、次のエラーで止まります。

$ naga bad_int_io.wgsl
error: Entry point vs_main at Vertex is invalid
  = `@interpolate(flat) must be explicitly specified for integer I/O

直し方は @location(0) @interpolate(flat) index: i32 と明示することです。古い解説にある [[stage(vertex)]] のような二重角括弧の属性も現在は使えず、@vertex・@builtin(...)・@location(n) が正しい書き方です。シェーダーを変えたら、Rustをビルドする前に naga で検証すると原因の切り分けが早くなります。

バックエンドの選ばれ方と環境変数

READMEの対応表を要約すると、Vulkanは「Windows・Linux・Android」、Metalは「macOS・iOS」、DX12は「Windows」がFirst Class(第一級)の対応です。OpenGLはWindowsでGL 3.3以上、Linux/AndroidでGL ES 3.0以上が「Downlevel/Best Effort」で、macOS/iOSではANGLE(angle feature)が要ります。macOSでVulkanを使うにはMoltenVKが要ります。Backends::PRIMARY はVulkan・Metal・DX12・ブラウザのWebGPUの4つ、Backends::SECONDARY はGLです。

どのGPUが使えるかは、アダプタを列挙すれば確かめられます。

// 使えるアダプタを列挙し、環境変数で選ぶ
fn main() {
    pollster::block_on(async {
        // *_from_env を使ったときだけ WGPU_BACKEND などの環境変数が効く
        let instance =
            wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle_from_env());
        for a in instance.enumerate_adapters(wgpu::Backends::all()).await {
            let i = a.get_info();
            println!("{:?} / {} / {:?}", i.backend, i.name, i.device_type);
        }
        let adapter = wgpu::util::initialize_adapter_from_env_or_default(&instance, None)
            .await
            .expect("アダプタが無い");
        println!("選ばれた: {}", adapter.get_info().name);
        let l = adapter.limits();
        println!("max_buffer_size={} max_compute_workgroup_size_x={}", l.max_buffer_size, l.max_compute_workgroup_size_x);
    });
}

実行結果です(長い行は一部省略)。

$ cargo run --release --bin adapters
Metal / AMD Radeon Pro 5500M / DiscreteGpu
Metal / Intel(R) UHD Graphics 630 / IntegratedGpu
選ばれた: AMD Radeon Pro 5500M
max_buffer_size=3208642560 max_compute_workgroup_size_x=1024

$ WGPU_ADAPTER_NAME=intel cargo run --release --bin adapters
選ばれた: Intel(R) UHD Graphics 630

$ WGPU_BACKEND=vulkan cargo run --release --bin adapters
アダプタが無い: NotFound { ... requested_backends: Backends(VULKAN), supported_backends: Backends(METAL), ... }

既定のfeatureでビルドしたmacOS版は、対応バックエンドがMetalだけでした。Vulkanを要求するとエラーメッセージの supported_backends にその理由が出ます。WGPU_ADAPTER_NAME はアダプタ名の部分一致で選ぶ変数で、この検証ではディスクリートGPUのRadeon Pro 5500Mと、CPU内蔵のIntel UHD Graphics 630を切り替えられました。ただし、WGPU_BACKEND などの環境変数が効くのは、new_without_display_handle_from_env() のような *_from_env 系で作ったときだけです。前章の main.rs のように new_with_display_handle() で作ったInstanceは環境変数を読まないので、切り替えたいなら new_with_display_handle_from_env() に替えます。

ブラウザ(wasm)で動かすときの条件

wasm32-unknown-unknown 向けにビルドすると、WebGPUに対応したブラウザではブラウザのWebGPUの上で動きます。ただし、この記事のコードはネイティブ向けなので、そのままでは動きません。pollster::block_on による同期待ちはブラウザでは使えず、非同期の初期化をブラウザのイベントループに任せる形へ書き換えます。読み戻しの完了待ちも同様で、PollType のドキュメントのとおり device.poll はWebGPUでは効果が無く、map_async のコールバックはブラウザのイベントループから呼ばれます。描画先もウィンドウではなくHTMLのcanvasです。

WebGPUが無いブラウザでWebGL2に落とすには、wgpu = { version = "30", features = ["webgl"] } とfeatureを足し、required_limits に Limits::downlevel_webgl2_defaults() などWebGL2で満たせる上限を明示して要求します。DeviceDescriptor::default() のままではWebGPUの既定上限を要求するため、デバイスの取得に失敗し得ます。この上限ではシェーダー段ごとのstorage bufferの数が0です。この記事のCompute Shaderのようにstorage bufferを使う処理はWebGL2では動かないので、GPU計算をブラウザで使うならWebGPU対応ブラウザを前提にします。JavaScript側との連携はWebAssembly(Wasm)とは?JavaScript連携とMemory・Table操作の実装、WebGL自体の仕組みはWebGLとは?仕組み・対応ブラウザの確認・動くコードとUnity WebGLビルドの手順【2026年】で解説しています。

wgpu 28〜30の破壊的変更と古いサンプルの直し方

古いwgpuのサンプルを新しいメジャーでビルドすると、API変更によるコンパイルエラーが起こります。CHANGELOGから、入門コードに出てくる箇所だけを抜き出しました。本記事の2026年7月版のコードも、wgpu 30.0.1で実際にビルドしたところ、表の1・3・4・6行目に当たる4か所でコンパイルが通りませんでした。

旧コード 現行の書き方 変更版
InstanceDescriptor::default() new_with_display_handle(..) など v29
get_current_texture()? match CurrentSurfaceTexture v29
frame.present() queue.present(frame) v30
RenderPassDescriptorに項目なし multiview_mask: None v28
multiview: None(パイプライン) multiview_mask: None v28
get_mapped_range(..) で値 Result を返す v30
buffers: &[layout] buffers: &[Some(layout)] v30
bind_group_layouts: &[&bgl] &[Some(&bgl)] v29
整数の @location のみ @interpolate(flat) を明示 v30

v29ではマップしたバッファへの書き込みも変わり、MapMode::Write の get_mapped_range_mut() は &mut [u8] として扱えなくなりました。copy_from_slice で丸ごと書く使い方は大きく変わりませんが(v30では先に Result を取り出す)、view[start..end] のような添字アクセスは view.slice(start..end) に書き換えます。

版を上げるときは、CHANGELOGの「Major changes」に載っているdiffを1つずつ当て、Learn Wgpuのように最新版へ追従している教材と照らし合わせるのが確実です。2026年10月時点でLearn Wgpuのリポジトリは wgpu = "30.0" に更新されています。

エラーの捕捉とデバッグの手順

wgpuは、APIの誤用(サイズの合わないバッファ、シェーダーの検証エラーなど)を自分で検出します。既定では、検出したエラーは「wgpu error: Validation Error」としてパニックします。パニックさせずに受け取りたい処理は、エラースコープで囲みます。v30の push_error_scope はガードを返し、pop() でエラーを受け取ります。次のRustコードを src/bin/error_scope.rs に置き、わざと検証エラーになるシェーダーを src/bad_int_io.wgsl として用意します。シェーダーは前章のnagaのエラー例と同じものです。

struct VsOut {
    @builtin(position) pos: vec4<f32>,
    @location(0) index: i32,
};
@vertex
fn vs_main(@builtin(vertex_index) i: u32) -> VsOut {
    var out: VsOut;
    out.pos = vec4<f32>(0.0, 0.0, 0.0, 1.0);
    out.index = i32(i);
    return out;
}
// バリデーションエラーをパニックさせずに受け取る
fn main() {
    pollster::block_on(async {
        let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
        let adapter = instance.request_adapter(&Default::default()).await.unwrap();
        let (device, _queue) = adapter.request_device(&Default::default()).await.unwrap();

        let scope = device.push_error_scope(wgpu::ErrorFilter::Validation);
        let _module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
            label: Some("bad shader"),
            source: wgpu::ShaderSource::Wgsl(include_str!("../bad_int_io.wgsl").into()),
        });
        if let Some(err) = scope.pop().await {
            println!("捕捉したエラー:\n{err}");
        }

        // スコープ外のエラーは既定でパニックになる
        println!("--- スコープ無し ---");
        let _module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
            label: Some("bad shader 2"),
            source: wgpu::ShaderSource::Wgsl(include_str!("../bad_int_io.wgsl").into()),
        });
        println!("ここまで到達");
    });
}
捕捉したエラー:
Validation Error

Caused by:
  In Device::create_shader_module, label = 'bad shader'

Shader validation error: Entry point vs_main at Vertex is invalid
...
--- スコープ無し ---

thread 'main' panicked at .../wgpu-30.0.1/src/backend/wgpu_core.rs:1084:30:
wgpu error: Validation Error

エラーメッセージに label = 'bad shader' と出ているとおり、各Descriptorの label に付けた名前はエラーとGPUデバッガの両方に表示されます。None のままにせず名前を付けておくと、どのリソースで失敗したかがすぐ分かります。アプリ全体でスコープ外のエラーを拾うなら device.on_uncaptured_error にハンドラを登録します。ログは env_logger::init() を呼んだうえで RUST_LOG=wgpu_core=warn,wgpu_hal=warn のように出力レベルを絞ると読みやすくなります。検証の強さは InstanceFlags で決まり、*_from_env 系で作ったInstanceなら WGPU_VALIDATION・WGPU_DEBUG・WGPU_GPU_BASED_VALIDATION などの環境変数で切り替えられます。

wgpu(Rust)のよくある質問

wgpuの最新バージョンはいくつですか?

2026年10月20日時点では30.0.1(2026年8月)です。メジャーはほぼ3か月ごとに上がるので、wgpu = "30" のようにメジャーで固定し、組み合わせるクレートとメジャーをそろえてください。

wgpuはPythonから使えますか?

使えます。wgpu-py(pip install wgpu、Python 3.11以上)を使います。0.32.0で「値を2倍にする」同じCompute Shaderを書くと、Radeon Pro 5500MのMetalで動き [0. 2. 4. 6. 8.] 1998.0 を返しました。

wgpu-rsとwgpuは同じものですか?

同じ系統です。以前はRust向けAPIが gfx-rs/wgpu-rs という別リポジトリで開発されていましたが、このリポジトリはアーカイブ済み(最終更新2021年6月)です。現在のRust APIは gfx-rs/wgpu リポジトリの wgpu クレートで、crates.ioのクレート名も wgpu です。

BevyやeguiでもwgpuのAPIを直接書く必要がありますか?

通常は不要です。Bevy 0.20は描画をwgpu 30の上に実装しており、eguiも描画をegui-wgpuに任せられます。自前のシェーダーや独自の描画パスを足すときに、この記事の初期化とパイプラインの知識が要ります。

wgpuのコードはブラウザで動きますか?

ブラウザ向けに移植すれば動きます。この記事のコードはネイティブ向けなので、非同期の初期化、canvasとの接続、読み戻しの完了待ちを書き換える必要があります。WebGL2にも対応させるなら webgl featureを足し、WebGL2用の上限を明示して要求します。WebGL2ではstorage bufferを使うGPU計算は動きません。

関連記事

お気に入りに入れた記事の一覧

この記事は以下の記事からリンクされています

資料請求

今日のトレンド記事 直近 24 時間で、いつもより多く読まれている記事

  1. 2026.10.21 テックブログ ふるさとチョイス不正アクセスで445万件流出:対象確認・パスワード変更と再開時期
  2. 2026.10.23 テックブログ Renta!不正アクセスでサービス停止:再開未定の現状と運営側の停止判断・証拠保全・段階的再開
  3. 2026.10.21 テックブログ NEC委託先の不正アクセスとは|顧客システム暗号化・情報漏えいの経緯と委託元が点検する正規アカウント
  4. 2026.10.24 テックブログ カプコン不正アクセス:CAPCOM ID 1,177万件漏えいと196件の乗っ取りへの対処
  5. 2026.10.24 テックブログ ブラストメール不正アクセス:配信先288件の漏えいと利用企業の報告・通知の手順

RELATED POSTS 関連記事

目次