メモ:nannou で素の lyon と wgpu-rs を使って描画する

nannou は Rust 製のクリエイティブコーディングフレームワークです。lyon は 2D グラフィックスのための tessellationのライブラリ、wgpu-rs は WebGPU を使うためのライブラリです。この 2 つは nannou の中でも使われているもので、黙っていれば nannou がすべて面倒を見てくれます。今回はそれをあえて使わずに素の lyon と wgpu-rs を使って図形を描画してみます。

なぜ?

Processing みたいにちょっとした gaussian blur をかけるのを試してみよう、と思ったときに気付いたんですが、 nannou にはまだ shader API がありません。なので、そういうことをしようと思うと自分でレンダリングパイプラインを組み立てる(用語の使い方あってる...?)必要に迫られます。逆に言えば、そのうちちゃんと API が用意されるはずなので、この記事に書くことはすぐに色褪せて世間に忘れられていくことでしょう。それでも。

ちなみに、nannou が wgpu を使うようになったのも数カ月前からです。wgpu 自体も開発途上で、API もガンガン変わっていくのでけっこうついていくのが大変そうではあります。

参考資料

wgpu については、このチュートリアルがめっちゃ詳しくてオススメです。正直写経するだけで精一杯でぜんぜん内容が頭に入ってきませんでしたが、それも含めて雰囲気を味わうのに丁度いいでしょう。

nannou で wgpu を使う例は nannou のレポジトリにコードがいくつか転がっています。

あと、lyon と wgpu を使うのは lyon のレポジトリに例が転がっています。

要はこの 2 つのコード例を組み合わせるだけでだいたい完成なんですが、躓いたところをメモっておきます。

fragment shader、vertex shader

とりあえずシンプルに、渡した vertex を赤色で描画する shader を使います。GLSL ファイルを書いたら、glslangValidator -V であらかじめ SPIR-V 形式に変換しておきます。

#version 450

layout(location = 0) in vec2 position;

void main() {
    gl_Position = vec4(position, 0.0, 1.0);
}
#version 450

layout(location = 0) out vec4 f_color;

void main() {
    f_color = vec4(1.0, 0.0, 0.0, 1.0);
}

model()

model() ではまず、 ウィンドウをつくり(ここは nannou の API)、そのウィンドウの swap chain が動いている GPUバイスを取り出します。

    let w_id = app.new_window().size(512, 512).view(view).build().unwrap();

    // The gpu device associated with the window's swapchain
    let window = app.window(w_id).unwrap();
    let device = window.swap_chain_device();

次に lyon の path を組み立てます。ここでは quadratic_bezier_to() で適当な曲線を引いています。 ちなみに、この座標は nannou と違って x、y それぞれ [-1,1] の座標系なので注意しましょう。 (あと、このコードは今の開発版の lyon を使ってるので begin() あたりが今の最新版と違う。注意)

    // Build a Path.
    let mut builder = Path::builder();
    builder.begin(point(0.0, 0.0));
    builder.quadratic_bezier_to(point(1.5, 0.7), point(0.2, -0.9));
    builder.end(false);
    let path = builder.build();

path ができたら、 VerteBuffer を作って、そこに書き込みましょう。 fill を書き込むには FillTessellatortessellate_path()、 stroke を書き込むには StrokeTessellatortessellate_path() がそれぞれ使えます (上の shader だと全部赤くなるので fill と stroke を両方書き込んでも見分けられないんですけどとりあえず両方用意します) 。注意点としては、

  • toleranceはけっこう小さくしておかないとベジェ曲線がカクカクになります。
  • stroke は with_line_width() を指定しないとデフォルトが 1.0 ?で極太の線が引かれます。
    let mut geometry: VertexBuffers<Vertex, u16> = VertexBuffers::new();

    let tolerance = 0.0001;

    let mut fill_tess = FillTessellator::new();
    let fill_count = fill_tess
        .tessellate_path(
            &path,
            &FillOptions::tolerance(tolerance).with_fill_rule(tessellation::FillRule::NonZero),
            &mut BuffersBuilder::new(&mut geometry, |vertex: tessellation::FillVertex| Vertex {
                position: vertex.position().to_array(),
            }),
        )
        .unwrap();

    let mut stroke_tess = StrokeTessellator::new();
    stroke_tess
        .tessellate_path(
            &path,
            &StrokeOptions::tolerance(tolerance).with_line_width(0.005),
            &mut BuffersBuilder::new(&mut geometry, |vertex: tessellation::StrokeVertex| Vertex {
                position: vertex.position().to_array(),
            }),
        )
        .unwrap();

この Range はあとで draw_indexed() する時に使います。

    let fill_range = 0..fill_count.indices;
    let stroke_range = fill_range.end..(geometry.indices.len() as u32);

VertexBuffer から vertex 用と index 用の Buffer をそれぞれつくります。

    let vertex_buffer = device.create_buffer_with_data(
        bytemuck::cast_slice(&geometry.vertices),
        wgpu::BufferUsage::VERTEX,
    );

    let index_buffer = device.create_buffer_with_data(
        bytemuck::cast_slice(&geometry.indices),
        wgpu::BufferUsage::INDEX,
    );

bind group layout、bind group、render pipeline layout、render pipeline をそれぞれつくります。この辺は nannou がビルダーを用意してくれているので素の wgpu に比べてコード量が少なくてすみます(この wgpu:: は wgpu crate ではなく nannou の wgpu module です。分かりづらい...)。注意点は、

  • primary topology は TriangleList
    // Load shader modules.
    let vs_mod = wgpu::shader_from_spirv_bytes(device, include_bytes!("shaders/vert.spv"));
    let fs_mod = wgpu::shader_from_spirv_bytes(device, include_bytes!("shaders/frag.spv"));

    // Create the render pipeline.
    let bind_group_layout = wgpu::BindGroupLayoutBuilder::new().build(device);
    let bind_group = wgpu::BindGroupBuilder::new().build(device, &bind_group_layout);
    let pipeline_layout = wgpu::create_pipeline_layout(device, &[&bind_group_layout]);
    let render_pipeline = wgpu::RenderPipelineBuilder::from_layout(&pipeline_layout, &vs_mod)
        .fragment_shader(&fs_mod)
        .color_format(Frame::TEXTURE_FORMAT)
        .add_vertex_buffer::<Vertex>(&wgpu::vertex_attr_array![0 => Float2])
        .index_format(wgpu::IndexFormat::Uint16)
        .sample_count(window.msaa_samples())
        .primitive_topology(wgpu::PrimitiveTopology::TriangleList)
        .build(device);

最後にここまで作ったものを Model に突っ込んで model() は完了です。

view()

まずは frame から command encoder を取り出します。

    let mut encoder = frame.command_encoder();

次に render pass をつくります。これもビルダーがあって短く書けて便利。 clear_color() は背景の色の指定です。

    let mut render_pass = wgpu::RenderPassBuilder::new()
        .color_attachment(frame.texture_view(), |color| {
            color.clear_color(wgpu::Color {
                r: 0.6,
                g: 0.6,
                b: 0.6,
                a: 1.0,
            })
        })
        .begin(&mut encoder);

そして、 draw_indexed() で描画します。上に書いたように、 fill と stroke を同時に描くとあんまりよくわからないのでひとまず stroke だけ描きます。

    render_pass.set_bind_group(0, &model.bind_group, &[]);
    render_pass.set_pipeline(&model.render_pipeline);
    render_pass.set_index_buffer(&model.index_buffer, 0, 0);
    render_pass.set_vertex_buffer(0, &model.vertex_buffer, 0, 0);

    // render_pass.draw_indexed(model.fill_range.clone(), 0, 0..1);
    render_pass.draw_indexed(model.stroke_range.clone(), 0, 0..1);

結果

こんな感じのが出るはず。

f:id:yutannihilation:20200618234100p:plain

コード全体はここで見れます。

感想

今回は静止画を描くだけだったので model() で完結しましたが、動くものを描くときは lyon::path::Path::Builder とか lyon::tessellation::VertexBuffer は使い回すのかとか、そのあたりまだぜんぜん理解できていないので learn-wgpu を読み返そうと思います。あとは、そもそもこっからどうやって gaussian blur みたいなのをかける render pipeline に持っていくのか理解できてない...