PR

Rust と wgpu を使った描画サイズの切り替え

1. はじめに

前回、三角形のポリゴンを描画するところまでやりました。
今回は描画するサイズを切り替えるところを試します。
GitHub Copilot の Agent モードも使ってみます。

2. Agent に対しての指示出し

まず、Agent モードに切り替え「描画サイズを切り替えるようにして」と指示します。
以下の指示を出しました。

現在、WebAssembly で表示するサイズを横640、縦480 固定で表示しています
これを初期描画サイズとした上で
1. 横640、縦480
2. 横1024、縦768
3. フルスクリーン
に切り替えるボタンをindex.htmlに追加し描画サイズを切り替えるようにしたい

フルスクリーン時はEscキーを押すことで 1. のサイズに戻したい
lib.rs の start_async() をある程度処理をまとめて複数のファイルに分割して描画の切り替えに対応できるようにしてください

すると、Agent は要件を整理し対応手順を出してくれました。
1 つ目は、描画サイズを切り替えるためのボタンを追加すること。
2 つ目は、描画サイズを切り替えるための処理を分割すること。
順次対応してくれました。

3. index.html の修正内容

まず、ボタンを追加。

    <div>
        <button id="btn640">640x480</button>
        <button id="btn1024">1024x768</button>
        <button id="btnFullscreen">フルスクリーン</button>
    </div>

次に、ボタンのイベントを追加。

        const canvas = document.getElementById('Canvas');
        document.getElementById('btn640').onclick = () => {
            if (document.fullscreenElement) document.exitFullscreen();
            canvas.width = 640;
            canvas.height = 480;
            resize(640, 480);
        };
        document.getElementById('btn1024').onclick = () => {
            if (document.fullscreenElement) document.exitFullscreen();
            canvas.width = 1024;
            canvas.height = 768;
            resize(1024, 768);
        };
        document.getElementById('btnFullscreen').onclick = () => {
            if (!document.fullscreenElement) {
                canvas.requestFullscreen();
            }
        };
        document.addEventListener('fullscreenchange', () => {
            if (document.fullscreenElement === canvas) {
                // フルスクリーン時は画面サイズに合わせる
                const w = window.screen.width;
                const h = window.screen.height;
                canvas.width = w;
                canvas.height = h;
                resize(w, h);
            } else {
                // フルスクリーン解除時は640x480に戻す
                canvas.width = 640;
                canvas.height = 480;
                resize(640, 480);
            }
        });
        document.addEventListener('keydown', (e) => {
            if (e.key === 'Escape' && document.fullscreenElement === canvas) {
                document.exitFullscreen();
            }
        });

ボタンを押したときの処理は、canvas のサイズを変更し、resize() を呼び出すようにしています。
お望み通りの修正をしてくれました。

4. lib.rs の修正

index.html からの呼び出しに対応するため、lib.rs の start_async() を分割してました。
lib.rs は開始とリサイズ、描画のみと以下のように修正されました。

use wasm_bindgen::prelude::*;
use web_sys::*;
use std::cell::RefCell;
mod renderer;
use renderer::Renderer;
thread_local! {
    static RENDERER: RefCell<Option<Renderer<'static>>> = RefCell::new(None);
}

#[wasm_bindgen]
pub async fn start() {
    wasm_bindgen_futures::spawn_local(async move {
        start_async().await;
    });
}

async fn start_async() {
    std::panic::set_hook(Box::new(console_error_panic_hook::hook));
    console_log::init_with_level(log::Level::Debug).expect("error initializing log");

    log::info!("start function called");

    let document = web_sys::window().unwrap().document().unwrap();
    let canvas = document.get_element_by_id("Canvas").unwrap();
    let canvas: web_sys::HtmlCanvasElement = canvas
        .dyn_into::<web_sys::HtmlCanvasElement>()
        .map_err(|_| ())
        .unwrap();

    let renderer = Renderer::new(canvas.clone()).await;
    RENDERER.with(|r| {
        *r.borrow_mut() = Some(unsafe { std::mem::transmute::<Renderer<'_>, Renderer<'static>>(renderer) });
    });
    render();
}

#[wasm_bindgen]
pub fn resize(width: u32, height: u32) {
    RENDERER.with(|r| {
        if let Some(renderer) = r.borrow_mut().as_mut() {
            renderer.resize(width, height);
            renderer.render();
        }
    });
}

#[wasm_bindgen]
pub fn render() {
    RENDERER.with(|r| {
        if let Some(renderer) = r.borrow().as_ref() {
            renderer.render();
        }
    });
}

5. renders.rs の追加

新たに renderer.rs を作成し、描画処理を移動しています。

// 描画・リサイズ処理をまとめるrenderer.rs
use wgpu::*;
use wgpu::util::DeviceExt;

// Vertex/Verticesはlib.rsから削除済みなので、ここで定義
#[repr(C)]
#[derive(Copy, Clone, Debug)]
pub struct Vertex {
    pub position: [f32; 3],
    pub color: [f32; 3],
}

unsafe impl bytemuck::Pod for Vertex {}
unsafe impl bytemuck::Zeroable for Vertex {}

const VERTICES: &[Vertex] = &[
    Vertex { position: [0.0, 0.5, 0.0], color: [1.0, 0.0, 0.0] },
    Vertex { position: [-0.5, -0.5, 0.0], color: [0.0, 1.0, 0.0] },
    Vertex { position: [0.5, -0.5, 0.0], color: [0.0, 0.0, 1.0] },
];

pub struct Renderer<'a> {
    pub surface: wgpu::Surface<'a>,
    pub device: wgpu::Device,
    pub queue: wgpu::Queue,
    pub config: wgpu::SurfaceConfiguration,
    pub render_pipeline: wgpu::RenderPipeline,
    pub vertex_buffer: wgpu::Buffer,
    pub canvas: web_sys::HtmlCanvasElement,
}

impl<'a> Renderer<'a> {
    pub async fn new(canvas: web_sys::HtmlCanvasElement) -> Self {
        let instance = wgpu::Instance::new(&wgpu::InstanceDescriptor {
            backends: wgpu::Backends::all(),
            ..Default::default()
        });
        let surface_target = wgpu::SurfaceTarget::Canvas(canvas.clone());
        let surface = instance.create_surface(surface_target).expect("Failed to create surface");
        let adapter = instance.request_adapter(&wgpu::RequestAdapterOptions {
            compatible_surface: Some(&surface),
            power_preference: wgpu::PowerPreference::default(),
            force_fallback_adapter: false,
        }).await.expect("Failed to find an appropriate adapter");
        let (device, queue) = adapter.request_device(&wgpu::DeviceDescriptor {
            label: None,
            required_features: wgpu::Features::empty(),
            required_limits: wgpu::Limits::downlevel_webgl2_defaults(),
            memory_hints: Default::default(),
            trace: wgpu::Trace::default(),
        }).await.expect("Failed to create device");
        let surface_capabilities = surface.get_capabilities(&adapter);
        let surface_format = surface_capabilities.formats.iter().find(|f| f.is_srgb()).copied().unwrap_or(surface_capabilities.formats[0]);
        let config = wgpu::SurfaceConfiguration {
            usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
            format: surface_format,
            width: canvas.width() as u32,
            height: canvas.height() as u32,
            present_mode: wgpu::PresentMode::Fifo,
            alpha_mode: wgpu::CompositeAlphaMode::Auto,
            view_formats: vec![],
            desired_maximum_frame_latency: 2,
        };
        surface.configure(&device, &config);
        let vertex_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
            label: Some("Vertex Buffer"),
            contents: bytemuck::cast_slice(VERTICES),
            usage: wgpu::BufferUsages::VERTEX,
        });
        let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
            label: Some("Shader"),
            source: wgpu::ShaderSource::Wgsl(include_str!("shader.wgsl").into()),
        });
        let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
            label: Some("Pipeline Layout"),
            bind_group_layouts: &[],
            push_constant_ranges: &[],
        });
        let render_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
            label: Some("Render Pipeline"),
            layout: Some(&pipeline_layout),
            vertex: wgpu::VertexState {
                module: &shader,
                entry_point: Some("vs_main"),
                buffers: &[wgpu::VertexBufferLayout {
                    array_stride: std::mem::size_of::<Vertex>() as wgpu::BufferAddress,
                    step_mode: wgpu::VertexStepMode::Vertex,
                    attributes: &[
                        wgpu::VertexAttribute {
                            format: wgpu::VertexFormat::Float32x3,
                            offset: 0,
                            shader_location: 0,
                        },
                        wgpu::VertexAttribute {
                            format: wgpu::VertexFormat::Float32x3,
                            offset: 12,
                            shader_location: 1,
                        },
                    ],
                }],
                compilation_options: wgpu::PipelineCompilationOptions::default(),
            },
            fragment: Some(wgpu::FragmentState {
                module: &shader,
                entry_point: Some("fs_main"),
                targets: &[Some(surface_format.into())],
                compilation_options: wgpu::PipelineCompilationOptions::default(),
            }),
            primitive: wgpu::PrimitiveState {
                topology: wgpu::PrimitiveTopology::TriangleList,
                strip_index_format: None,
                front_face: wgpu::FrontFace::Ccw,
                cull_mode: Some(wgpu::Face::Back),
                polygon_mode: wgpu::PolygonMode::Fill,
                unclipped_depth: false,
                conservative: false,
            },
            depth_stencil: None,
            multisample: wgpu::MultisampleState {
                count: 1,
                mask: !0,
                alpha_to_coverage_enabled: false,
            },
            multiview: None,
            cache: None,
        });
        Self {
            surface,
            device,
            queue,
            config,
            render_pipeline,
            vertex_buffer,
            canvas,
        }
    }

    pub fn resize(&mut self, width: u32, height: u32) {
        self.config.width = width;
        self.config.height = height;
        self.canvas.set_width(width);
        self.canvas.set_height(height);
        self.surface.configure(&self.device, &self.config);
    }

    pub fn render(&self) {
        let output = self.surface.get_current_texture().unwrap();
        let output_view = output.texture.create_view(&wgpu::TextureViewDescriptor::default());
        let mut encoder = self.device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
            label: Some("Command Encoder"),
        });
        {
            let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
                label: Some("Render Pass"),
                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
                    view: &output_view,
                    resolve_target: 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,
            });
            render_pass.set_pipeline(&self.render_pipeline);
            render_pass.set_vertex_buffer(0, self.vertex_buffer.slice(..));
            render_pass.draw(0..VERTICES.len() as u32, 0..1);
        }
        self.queue.submit(Some(encoder.finish()));
        output.present();
    }
}

6. ビルドと修正

ビルドは以下のようにします。

cargo build --target wasm32-unknown-unknown

ビルドして出てきたエラーや警告はコピペして Agent に投げて修正してもらいました。
何度かやり取りをして、最終的にすべてのエラーと警告が消えました。

ビルド後、wasm-bindgen を使ってバインディングを生成します。

wasm-bindgen .\target\wasm32-unknown-unknown\debug\wasm_wgpu.wasm --out-dir .\target\wasm32-unknown-unknown\debug\ --target web

7. 表示

以下が表示されます。(Chromeで確認)

8. まとめ

Agent に指示しか出してない・・・。
こんなに簡単にできるのかと驚きました。
プログラマー廃業・・・、と思ったけどよく考えたら自分が望む状況に近づいただけでした。
指示を出すのがちょっと難しいですが、これは慣れれば簡単にできると思います。

今回の記事は、Github Copilot が9割くらい書いてくれました。

A. 参考サイト

GitHub Copilot Agent Modeとは?

B. 関連書籍

コメント