前回 は、WebGPUのアダプタと論理デバイスの初期化についてお話ししました。

論理デバイスが取得できたら、「WebGPUでなにかする」準備は実はもう整っています。
ここから先何をするか、ざっくり分ければ2通りの道があります。その1つがキャンバスへの描画です。一方、キャンバスとは無関係な計算処理をGPUに任せる方法を学ぶこともできます。

ここではまず、キャンバスへの描画を目指して進んでいくことにしましょう。

目標:キャンバスの色を初期化する

まずは、キャンバスの色を初期化する(キャンバス全体を単色で塗りつぶす)だけの簡単なデモの実装を目指します。

ちなみに、WebGLでは次のようなコードで実現できました。

WebGLでキャンバスの色を初期化する
const canvas = document.querySelector("canvas")
const gl = canvas.getContext("webgl2")
 
gl.clearColor(0.749, 0.925, 1.0, 1)
gl.clear(gl.COLOR_BUFFER_BIT)

WebGLでは、glコンテキスト(WebGL2RenderingContext)を直接操作して、キャンバスの色を初期化しています。このように、glコンテキストが持つグローバルな状態を書き換えながら描画処理を進めていくのが、WebGLのスタイルです。

では、WebGPUの場合はどのようなアプローチになるのでしょうか。

WebGPUとキャンバスの連携

Canvasコンテキストの取得

アダプタと論理デバイスの取得は、WebGPU自体を初期化するためのものです。
WebGPUでキャンバスに描画したい場合は、さらにキャンバスの初期化を行う必要があります。

const canvas = document.querySelector("canvas")

キャンバスの初期化に使うのが、GPUCanvasContextというコンテキストです。
これは、canvas要素から、次のように取得することができます。

const context = canvas.getContext("webgpu")

Canvasと論理デバイスの紐付け

続いて、キャンバスをWebGPUの描画先として設定するためのコードを書きます。
この設定はcontext.configure()メソッドで行います。このメソッドの引数として指定するのは、主に次の2つの情報です。

  • このキャンバスに描画するには、どのGPUデバイスを使うのか?という情報(論理デバイス)
  • 描画結果を出力するときの色の表現方法(カラーフォーマット)
const format = navigator.gpu.getPreferredCanvasFormat()
context.configure({ device, format })

gpu.getPreferredCanvasFormat()メソッドによって、ブラウザとGPUが最適とするキャンバスのカラーフォーマットを取得できます。
多くの環境では"bgra8unorm"が返ってくることが多いですが、将来のブラウザやデバイスによって最適な形式が変わる可能性もあるため、ハードコードせずこの関数を使うのが推奨されています。

論理デバイス(device)は、実際のGPUに対する操作の窓口であり、バッファやテクスチャなどのGPUリソースの作成元でもあります。
deviceに紐づいたリソースを使うためには、キャンバスの描画先も同じdeviceで管理されている必要があるため、context.configure()メソッドでは論理デバイスも指定します。

キャンバスと論理デバイスを明示的に紐づけることには、セキュリティ上の意味もあります。「使っていいGPUはこれだよ」と明示することで、リソースの誤使用を防ぐのです。

GPUに仕事を依頼する

これで、描画の対象となる場所は設定できました。
実際の描画処理を書く前に、GPUに描画処理をお願いするための準備が必要になります。

コマンドのエンコード

WebGPUでは、GPUに何か処理(描画や計算など)をしてもらうためには、CPU側で命令(コマンド)を用意して、それをまとめてGPUに渡す必要があります。
このコマンドの準備・記録・構築作業のことをエンコード(encode)と呼びます。GPUが理解できる形式で「やることリスト」を作るイメージです。

さて、この「エンコード」の作業を担うのが、コマンドエンコーダGPUCommandEncoder)です。

// コマンドエンコーダを作成する(「やることリスト」を作成開始)
const commandEncoder = device.createCommandEncoder()

コマンドエンコーダを作成したら、コマンドエンコーダに対して描画や計算などの指示を出していきます。コマンドエンコーダは、これらの指示を順番に記録してくれます。

実際にどのように指示を出すかは、WebGPUを描画に使うのか計算処理に使うのかで異なるため、実際のコード例は後述します。

// コマンドを記録していく(「やることリスト」にタスクを積む)

指示を一通り書き終えたら、finish()メソッドを呼び出して、命令をGPUが送信できる形にまとめます。

// GPUに送信できる形にまとめる
const commandBuffer = commandEncoder.finish()

finish()メソッドから返ってくるコマンドバッファ(GPUCommandBuffer)は、実際にGPUに渡す命令書のようなものです。

コマンドの送信

GPUが読める命令書であるコマンドバッファを作成したら、次はそれをGPUに送信します。
GPUにコマンドを送信するための窓口がキューGPUQueue)です。

// コマンドバッファをGPUに送信する
device.queue.submit([commandBuffer])

queue.submit()メソッドを呼び出すと、GPUが命令書を受け取り、実行を開始します。

補足:コマンドバッファは使い捨て

ところで、コマンドバッファは一度GPUに送信(submit())したら、再利用することはできません。GPUがコマンドバッファを消費してしまうからです。

そのため、コマンドバッファ(finish()の戻り値)はわざわざ変数にはせず、queue.submit()メソッドの引数内でfinish()を呼び出してしまうコードが一般的です。

今までのコード
const commandBuffer = commandEncoder.finish()
device.queue.submit([commandBuffer])
👍 一般的な書き方
device.queue.submit([commandEncoder.finish()])

次のステップ:描画の指示を組み立てる

ここまで、GPUにコマンドを送るまでの流れを見てきましたが、具体的にどんなコマンドを送るかについては保留していました。

const commandEncoder = device.createCommandEncoder()
 
//
// TODO: 具体的な描画コマンドを記録する
//
 
device.queue.submit([commandEncoder.finish()])

ここからは、実際に描画するための具体的なコマンドの組み立て方を見ていきましょう。

描画処理のグループ化

WebGPUは、正確なメモリ制御のために、「どこに」「どう描くか」を明確に分けて管理する仕組みになっています。

「どこに」「どう描くか」に応じて、それらの設定や描画に必要な作業をまとめたものがレンダリングパス(RenderPass)です。

レンダリングパスの設定

レンダリングパスは、「どこに」「どう描くか」ごとに描画処理をグループ化するものです。

1つのレンダリングパス(ひとまとまりの描画処理)を開始するときには、「どこに」「どう描くか」という設定をまとめたオブジェクト(GPURenderPassDescriptor)を与える必要があります。

TIP

WebGPUでは、「〜デスクリプタ」(xxxDescriptor)というような名前がつけられたオブジェクトが多数登場します。descriptionは「説明」という意味を持つ英単語ですから、descriptorとは、こんなふうに動いてほしい!という意図をGPUに対して「説明してくれるもの」と解釈しておくと、イメージしやすいかもしれません。

GPURenderPassDescriptorに最低限必要なのは、アタッチメント(描画対象とする画像)の設定です。

アタッチメントの設定はcolorAttachmentsプロパティに指定します。
colorAttachmentsプロパティに指定するオブジェクトには、まさに「どこに」「どう描くか」という設定が集約されています。

const renderPassDescriptor = {
  colorAttachments: [
    {
      // 描画先の画像
      view: context.getCurrentTexture().createView(), // キャンバスに描画する
 
      // 画像をどのように更新するか
      loadOp: "clear", // 毎回初期化する
      storeOp: "store", // 最終的に描いた内容を保存する
 
      // 画像を初期化するときの色
      clearValue: { r: 0.749, g: 0.925, b: 1.0, a: 1 }
    }
  ]
}

colorAttachments.viewには、どの画像に描画するかを指定します。
context.getCurrentTexture().createView()を指定することで、canvas要素に描画することができます。

IMPORTANT

getCurrentTexture()createView()については、今後の記事で詳しく解説します。

loadOpはこのレンダリングパスでの描画前、storeOpは描画後に、画像データをどう扱うかを指定するものです。

プロパティ指定する内容値に応じた挙動
loadOpすでに描かれている内容をどうするか?"clear" で初期化、"load" で残す
storeOp今、描いた結果を保存するか?"store" で保存、"discard" で破棄

loadOp"clear"の場合は、colorAttachments.clearValueで指定した色で画像を初期化します。

レンダリングパスの実行

レンダリングパスの設定オブジェクトを用意したら、いよいよレンダリングパスを使ってひとまとまりの描画処理を開始することができます。

const commandEncoder = device.createCommandEncoder()
 
const renderPass = commandEncoder.beginRenderPass(renderPassDescriptor)
 
// renderPass.draw(...) など
 
renderPass.end() // 描画処理を終了
 
device.queue.submit([commandEncoder.finish()])

beginRenderPass()は、GPUに「これからこの画像(renderPassDescriptorで指定した画像)に絵を描きますよ!」と宣言して、描画用のコマンド記録を開始するメソッドです。

beginRenderPass()メソッドを呼ぶことで、描画専用のコマンドエンコーダであるGPURenderPassEncoderが得られます。このエンコーダに対して、描画専用のコマンド(draw()など)を記録していきます。

描画処理が終わったら、renderPass.end()メソッドを呼び出すことで、コマンドの記録を終了します。

発展:複数のレンダリングパスを使う場合もある

レンダリングパスは、「どこに」「どう描くか」に応じた描画の単位です。

そのため、「どこに」(出力先)や「どう描くか」(描画設定)が異なる場合は、複数のレンダリングパスに分けて描画処理を行う必要があります。

たとえば、「影 → ライト → 最終描画」のように、複数ステップで描画を仕上げるマルチパスレンダリングの場合、各工程で異なるテクスチャに描く必要があるため、それぞれ別のレンダリングパスを用意します。

他にも、背景と後から描画されるUIでは別々の設定(loadOp, blending, depthなど)を使いたい場合や、複数のレンダーターゲット(たとえばミニマップやセカンドビュー)を同時に表示したいときも、それぞれに対してレンダリングパスを作る必要があります。

複数必要になる条件具体的な例
出力先が異なるテクスチャ → キャンバスなどの複数ステップで描画
設定が異なるdepthあり/なし、blendあり/なしなどを切り替え
複数ビュー複数の画面やレイヤーを描画

実装:キャンバスの色を初期化する

ここまで解説した内容を順に組み合わせて、WebGPUでキャンバスの色を初期化するデモを実装することができます。(※エラー処理は省略しています。)

WebGPUでキャンバスの色を初期化する
const adapter = await navigator.gpu.requestAdapter()
const device = await adapter.requestDevice()
 
const canvas = document.querySelector("canvas")
const context = canvas.getContext("webgpu")
 
const format = navigator.gpu.getPreferredCanvasFormat()
context.configure({ device, format })
 
const commandEncoder = device.createCommandEncoder()
const renderPassDescriptor = {
  colorAttachments: [
    {
      view: context.getCurrentTexture().createView(),
      loadOp: "clear",
      clearValue: { r: 0.749, g: 0.925, b: 1.0, a: 1 },
      storeOp: "store"
    }
  ]
}
 
const renderPass = commandEncoder.beginRenderPass(renderPassDescriptor)
renderPass.end()
 
device.queue.submit([commandEncoder.finish()])

loadOpの値を"clear"にしているため、beginRenderPass()メソッドを呼び出した時点で、キャンバスの色が初期化されます。
今回は、初期化したキャンバス上に何かを描くわけではないので、すぐにend()メソッドを呼び出していることに注意してください。

const renderPass = commandEncoder.beginRenderPass(renderPassDescriptor)
renderPass.end()

実行結果は、次のようになります。

キャンバスの色を変えたい場合は、clearValueの値を変更してみましょう。

const renderPassDescriptor = {
  colorAttachments: [
    {
      view: context.getCurrentTexture().createView(),
      loadOp: "clear",
      clearValue: { r: 0.749, g: 0.925, b: 1.0, a: 1 },
      storeOp: "store"
    }
  ]
}

RGBAの値は、0 ~ 255の範囲の数値で指定するのが見慣れた形ですが、WebGPUでは、0.0 ~ 1.0の範囲の数値で指定する必要があります。

色名CSSで馴染みの表記clearValueに指定する値
rgba(255, 255, 255, 1){ r: 1, g: 1, b: 1, a: 1 }
rgba(255, 0, 0, 1){ r: 1, g: 0, b: 0, a: 1 }
rgba(0, 255, 0, 1){ r: 0, g: 1, b: 0, a: 1 }
rgba(0, 0, 255, 1){ r: 0, g: 0, b: 1, a: 1 }
rgba(0, 0, 0, 1){ r: 0, g: 0, b: 0, a: 1 }
グレーrgba(128, 128, 128, 1){ r: 0.5, g: 0.5, b: 0.5, a: 1 }

Next Step