前回 は、WebGPUを使って三角形を描画する処理を完成させました。
ところで、三角形の頂点の座標は頂点シェーダ内で配列として定義していましたが、通常はこのようなことはしません。
なぜなら、頂点のデータをシェーダ内で定義してしまうと、そのシェーダはその図形専用のシェーダになってしまうからです。
シェーダは、コンパイルしてパイプラインに組み込むことで、初めて利用できるようになります。
WebGPUのパイプライン作成はそれなりに重い処理であり、図形ごとにシェーダとパイプラインを用意して切り替えながら描画するのは非効率です。
そこで今回は、JavaScript側で頂点座標を定義するようにコードを書き換えていきます。
そして、JavaScript(CPU側)で用意した頂点座標のデータをシェーダ(GPU側)に渡すための仕組みとして、バッファを導入します。
JavaScript側で頂点データを用意する
前回実装した三角形を描画するコードでは、頂点シェーダ内で定義したpos
という配列に、3つの頂点の座標を定義していました。
@vertex
fn vs_main(in: VertexInput) -> VertexOutput {
var pos = array<vec2f, 3>(
vec2f( 0.0, 0.5),
vec2f(-0.5, -0.5),
vec2f( 0.5, -0.5)
);
var out: VertexOutput;
out.Position = vec4f(pos[in.VertexIndex], 0.0, 1.0);
return out;
}
この頂点の座標(pos
の中身)を、JavaScript側で定義するようにしましょう。
単純に移植しようとすると、次のようなコードが思い浮かぶかもしれません。1つの頂点の座標を1つの配列とし、それを3つ並べた「配列の配列」です。
const vertices = [
[0.0, 0.5],
[-0.5, -0.5],
[0.5, -0.5]
]
1つの頂点ごとの座標をそれぞれ配列でまとめたこのコードは、人間にとってはわかりやすいものです。
しかし、GPU(シェーダ)に渡す場合は、次のようにフラット(flat)な1次元配列で定義するようにします。GPUのメモリ上にデータを並べる上で、そのほうが都合がよいのです。
const vertices = [0.0, 0.5, -0.5, -0.5, 0.5, -0.5]
しかし、これでもまだGPU(シェーダ)に渡すには不十分です。
シェーダにデータを渡すときは、シェーダ側と各数値の型を合わせる必要があります。
WGSLによる頂点シェーダのコードでは、最終的に出力する座標@builtin(position)
はvec4f
型と指定されています。
struct VertexOutput {
@builtin(position) Position: vec4f
};
vec4f
はvec4<f32>
の省略形であり、つまり座標を表す各数値は32ビット浮動小数点数(f32
型)であることを意味しています。
シェーダに座標データを正しく効率的に渡すには、JavaScript側でも、座標を表す各数値が32ビット浮動小数点数であることを明示する必要があるのです。
そのためには、通常の配列ではなく、Float32Array
という型付きの配列を使います。
const vertices = new Float32Array([0.0, 0.5, -0.5, -0.5, 0.5, -0.5])
CPUとGPUはメモリが分かれている
JavaScript側で頂点座標を定義した場合、厄介な問題に直面します。
それは、JavaScriptはCPU側で動くプログラムであり、一方でシェーダはGPU側で動くプログラムであるため、データの管理場所(メモリ空間)が異なることです。
基本的にCPU側のメモリとGPU側のメモリ(デバイスメモリ)は分かれていることが多く、GPU(シェーダ)からCPU側のメモリの内容(JavaScript側で定義したデータ)を直接参照することはできません。
JavaScript側で用意した頂点座標のデータをシェーダで使うためには、そのデータをGPUからアクセスできるメモリにコピーする必要があります。
バッファを介したデータのやり取り
CPUとGPUの間でデータをやりとりする操作は、WebGPUでは「バッファというオブジェクト(GPUBuffer
)にデータを書き込む」という形で表現されます。
NOTE
一般に、バッファという言葉は、「データを一時的にためておく場所(メモリ領域)」という意味で使われます。
バッファの作成
WebGPUのバッファを作成するには、device.createBuffer()
メソッドを使います。
const vertexBuffer = device.createBuffer({
size: vertices.byteLength,
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST
})
バッファを作成する際には、「どこに」「どのくらいの」メモリ領域を確保したいのかを指定する必要があります。
WebGPUが「どこに」を判断するヒントとなるのがusage
で、「どのくらいの」を明示するのがsize
です。
CPU用とGPU用のメモリが分かれているだけでなく、GPU側のメモリも用途に応じて分かれているため、GPUプログラミングでは、データをどのメモリ領域に置くかを明確に区別する必要があります。
WebGPUでは、バッファの作成時にusage
フラグを指定することで、どのメモリ領域にデータを置くかをWebGPU側で判断してくれるようになっています。
フラグを複数指定する場合は、|
(ビットOR)演算子でつなげて指定します。
VERTEX
:頂点データとして使う(GPU側のメモリを確保する)COPY_DST
:コピー先(書き込み対象)として使う
今回は、GPUBufferUsage.VERTEX
とGPUBufferUsage.COPY_DST
という、2つのフラグを指定しました。
この組み合わせによって、「GPU側のメモリに頂点データ用の領域を確保し、CPU側からその領域にデータをコピーする」という意図をWebGPUに伝えています。
さて、バッファを作成しただけでは、バッファはただの空箱にすぎません。
作成したバッファにデータを書き込むには、device.queue
が持つwriteBuffer()
メソッドを使うのが便利です。
device.queue.writeBuffer(vertexBuffer, 0, vertices)
writeBuffer()
メソッドでは、ブラウザが最も効率のよい方法を判断し、データの書き込みを行ってくれます。
補足:互換性という「Webらしさ」
デバイスの種類に応じて、メモリの構成は異なります。スマートフォンなど一部のデバイスでは、GPUとCPUが同じメモリを共有していることもあります。
しかし、WebGPUのコードを書く上でそのような差異を気にする必要はありません。
WebGPUでは、すべての環境が「GPUとCPUが別々のメモリを持っている」という想定でコードを書くことができます。あとは、WebGPU側が動作環境に応じて最適化をしてくれるわけです。
このような設計になっているのは、WebGPUがWebブラウザ上で動くAPIだからです。WebGPU APIは、なるべく多くの種類のデバイスで同じコードが動作することを重視した設計になっています。
発展:作成時にマッピングして書き込む方法
先ほど解説したコードをまとめると、次のようなコードで頂点バッファの作成・書き込みを行っていました。
const vertexBuffer = device.createBuffer({
size: vertices.byteLength,
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST
})
device.queue.writeBuffer(vertexBuffer, 0, vertices)
このコードの代わりに、次のようなコードスニペットによって、頂点バッファの作成・書き込みを行うこともできます。
const vertexBuffer = device.createBuffer({
size: vertices.byteLength,
usage: GPUBufferUsage.VERTEX,
mappedAtCreation: true
})
new Float32Array(vertexBuffer.getMappedRange()).set(vertices)
vertexBuffer.unmap()
インターネットで調べると出てくるWebGPUのサンプルコードでは、むしろこちらのコードスニペットの方がよく見かけるかもしれません。
この新たな方法に登場する、未知の部分に注目してみましょう。
const vertexBuffer = device.createBuffer({
size: vertices.byteLength,
usage: GPUBufferUsage.VERTEX,
mappedAtCreation: true
})
new Float32Array(vertexBuffer.getMappedRange()).set(vertices)
vertexBuffer.unmap()
まず、mappedAtCreation: true
を指定することで、バッファの作成時にマッピングも同時に行います。
マッピングとは、GPU側のメモリをCPUから直接アクセスできるようにし、まるでCPUメモリの一部のように扱える状態にすることです。
const vertexBuffer = device.createBuffer({
size: vertices.byteLength,
usage: GPUBufferUsage.VERTEX,
mappedAtCreation: true
})
これで、バッファの作成後すぐに、JavaScript側から中身を直接書き込めるようになります。実際にデータの書き込みを行っているのが、次の行です。
new Float32Array(vertexBuffer.getMappedRange()).set(vertices)
getMappedRange()
メソッドでは、バッファの書き込み可能な範囲のメモリ領域をJavaScriptのArrayBuffer
オブジェクトとして取得します。
vertexBuffer.getMappedRange()
ただし、ArrayBuffer
オブジェクトが扱うのは、ただのバイナリデータです。
何バイトで1つの数値を表すのかは数値型によって異なるため、このバイナリデータを意味のある値として扱うためには、型付き配列でラップする必要があります。
ここでは、得られたメモリ領域を32ビット浮動小数点数の配列として扱うために、Float32Array
コンストラクタでラップしています。
new Float32Array(vertexBuffer.getMappedRange())
これで、GPU側のメモリ領域を、JavaScript側から32ビット浮動小数点数の配列として扱えるようになりました。
あとは、set()
メソッドを使って、JavaScript側で用意した頂点データ(vertices
)を直接書き込みます。
new Float32Array(vertexBuffer.getMappedRange()).set(vertices)
書き込みが終わったら、マッピングを解除して、GPUにメモリのアクセス権を返します。
WebGPUでは、マッピング状態のままだとGPU側がそのバッファを使えないので、次のunmap()
でロックを外す必要があります。
vertexBuffer.unmap()
最後に、usage
フラグからCOPY_DST
を外していることに注意してください。
GPU側のメモリに直接書き込むこの方式では、CPU側からGPU側へのデータのコピーが行われないため、COPY_DST
は不要になります。
const vertexBuffer = device.createBuffer({
size: vertices.byteLength,
usage: GPUBufferUsage.VERTEX,
mappedAtCreation: true
})
この作成時マッピング方式は、特にCPU側で動的に頂点データを生成する場合に効率がよい方法です。
JavaScriptの配列を作ってからGPU側のメモリにコピーするのではなく、GPU側のメモリへ直接書き込みながら処理を進めることで、余計なメモリコピーを省くことができます。
function generateTorusVertexBuffer({
radius = 1.0, // トーラスの中心からチューブ中心までの距離
tubeRadius = 0.4, // チューブ自体の半径
radialSegments = 32, // 外側リングの分割数
tubularSegments = 24 // チューブ側の分割数
} = {}) {
const vertexBuffer = device.createBuffer({
size: Float32Array.BYTES_PER_ELEMENT * 3 * (radialSegments + 1) * (tubularSegments + 1),
usage: GPUBufferUsage.VERTEX,
mappedAtCreation: true
})
const vertices = new Float32Array(vertexBuffer.getMappedRange())
for (let j = 0; j <= radialSegments; j++) {
const v = (j / radialSegments) * Math.PI * 2
const cosV = Math.cos(v)
const sinV = Math.sin(v)
for (let i = 0; i <= tubularSegments; i++) {
const u = (i / tubularSegments) * Math.PI * 2
const cosU = Math.cos(u)
const sinU = Math.sin(u)
const x = (radius + tubeRadius * cosV) * cosU
const y = (radius + tubeRadius * cosV) * sinU
const z = tubeRadius * sinV
// 直接書き込み
vertices[(j * (tubularSegments + 1) + i) * 3 + 0] = x
vertices[(j * (tubularSegments + 1) + i) * 3 + 1] = y
vertices[(j * (tubularSegments + 1) + i) * 3 + 2] = z
}
}
vertexBuffer.unmap()
return vertexBuffer
}
注意点として、mappedAtCreation: true
による作成時マッピング方式は、バッファの内容を後から変更しない場合にしか使うことができません。
バッファを使い回して内容だけ書き換えるような場合には、queue.writeBuffer()
による非マップ方式を使う必要があります。
WARNING
実は、ここでの説明はかなり簡略化してしまっています。
実際は、GPU側のメモリに直接書き込むことはできないため、CPUからアクセスできる中間メモリ(ステージングバッファ)に対してマッピング・書き込みを行い、そこからGPU側のメモリにコピーするという仕組みになっています。
現時点ではステージングバッファの存在を意識する必要はないため、このような説明に留めておきました。
後のコンピュートシェーダに関する回では、ステージングバッファを介したデータのやり取りについてもう少し深掘りすることになります。
バッファの中身とレイアウト
バッファの中身は、ただの連続したバイナリデータです。たとえば、[0.0, 1.0, 0.0, 1.0]
のような浮動小数点数(Float32
)がずらっと並んでいたり、[1, 2, 3, 4]
のような整数列だったりします。
しかし、GPUはこの数値の並びを「色」や「座標」として直接理解してくれるわけではありません。「このバッファのこの位置には、このようなデータが入っている」という説明(デスクリプタ)を一緒に渡す必要があります。
たとえば、「1つの頂点には、位置を表す3つの数値(x, y, z
)と、色を表す4つの数値(r, g, b, a
)があります」というような情報を、デスクリプタというオブジェクトで教えてあげることで、GPUはバッファの中身を正しく解釈できるようになるのです。
頂点バッファに関するデスクリプタは、2種類に分けられています。
- 属性デスクリプタ:1つの頂点に関するデータの並びの説明(ローカルな情報)
- レイアウトデスクリプタ:バッファに格納された全頂点に関するデータの並びの説明(グローバルな情報)
属性デスクリプタ
頂点バッファでは、各頂点の位置だけでなく、その頂点の色や、その頂点に対応するテクスチャの座標なども一緒に格納する場合もあります。
このような、位置、色、テクスチャ座標など、1つの頂点に関する情報を属性と呼ぶことがあります。
属性デスクリプタは、1つの頂点が持つ属性の「データの配置場所(@location
)」や「データ型(vec3<f32>
など)」を記述するものです。
レイアウトデスクリプタ
GPUに頂点データを送るとき、通常は複数の頂点をまとめた大きなバッファを一気に送信します。少量のデータを小分けで送るのは効率が悪いため、このようなバッチ送信が基本となります。
しかし、「1つの頂点ずつ」処理する頂点シェーダーでは、バッファ全体の情報を一度に見ることはできません。
レイアウトデスクリプタは、多くの頂点がまとめて格納されたバッファから、「各頂点のデータをどう取り出すか」というルールを定義するものです。
GPUは、レイアウトデスクリプタをもとに、各頂点に必要なデータをバッファから自動的に切り出し、シェーダの処理に使います。
用意した頂点バッファを使う
少し理論的な話が長くなってしまいましたが、三角形の描画コードに戻って、JavaScript側で用意した頂点バッファを使うようにコードを書き換えていきましょう。
バッファの作成(復習)
JavaScript側での頂点座標の定義は、次のようなコードで行えばよいのでした。
const vertices = new Float32Array([0.0, 0.5, -0.5, -0.5, 0.5, -0.5])
この頂点座標をシェーダ側に送るためのバッファは、次のように作成します。
const vertexBuffer = device.createBuffer({
size: vertices.byteLength,
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST
})
そして、配列に格納した座標データをこのバッファに書き込みます。
device.queue.writeBuffer(vertexBuffer, 0, vertices)
これらのコードは、ここまでの解説ですでに登場しているので、必要に応じて復習してみてください。
シェーダで頂点データを受け取る
バッファによってJS側から転送される頂点データを、シェーダ側で受け取るコードを追加していきます。
頂点シェーダの入力となるVertexInput
構造体を、次のように変更します。
struct VertexInput {
@location(0) position: vec2f
};
@location(0)
がどんな意味を持つかは、この後すぐに解説します。
vec2f
は、WGSLにおける2次元ベクトルの型であり、f32
型の数値を2つ並べたものを1つの頂点座標として扱うことを意味しています。
これで、VertexInput
構造体の中にposition
という変数を定義できました。
このposition
変数を頂点座標として使うように、頂点シェーダの本体であるvs_main
関数も変更しておきましょう。
@vertex
fn vs_main(in: VertexInput) -> VertexOutput {
var out: VertexOutput;
out.Position = vec4f(in.position, 0.0, 1.0);
return out;
}
データの読み方をシェーダに伝える
これでデータ自体を受け渡すことはできるようになりましたが、まだシェーダはこのデータを「どのように扱えばいいのか」を知らない状態です。
バッファの中身はただの連続したバイナリデータであり、「このバッファのこの位置には、このようなデータが入っている」という説明(デスクリプタ)を一緒に渡す必要があるのでした。
まずは、1つの頂点を表すデータの並びの説明である属性デスクリプタを定義します。
const positionAttribute = {
shaderLocation: 0, // データの配置場所(シェーダの`@location`に対応)
offset: 0, // バッファ内での読み出し開始位置
format: "float32x2" // データ型(f32型の数値を2つ並べたものが1つの頂点)
}
shaderLocation
は、このデータをシェーダ側の「どの変数に入れるか」を指定するものです。
shaderLocation: 0
と指定した場合は、WGSLシェーダ側のVertexInput
構造体のうち、@location(0)
という印をつけた変数にデータが格納されます。
offset
は、1つのバッファ内に複数の属性(頂点座標、色など)を含めた場合に、各属性がバッファ内のどの位置から始まるかを指定するものです。
今回は頂点座標だけを格納しているため、あまり気にする必要はありません。0
と指定しておけば、バッファの先頭から読み出しを始めることになります。
format
は、データの型を指定するものです。float32x2
という指定は、32ビット浮動小数点数(f32
)を2つ並べたものを1つの頂点として扱うことを意味しています。
これは、WGSLシェーダ側でposition
変数の型をvec2f
と指定したことと対応しています。
このように、1つの頂点を表すデータの構成を決めたら、今度は頂点バッファ全体のデータの並びの説明であるレイアウトデスクリプタを定義します。
const vertexBufferLayout = {
arrayStride: vertices.BYTES_PER_ELEMENT * 2, // 1つの頂点のデータのサイズ
attributes: [positionAttribute] // 属性デスクリプタの配列
}
これは、多くの頂点がまとめて格納されたバッファから、「各頂点のデータをどう取り出すか」というルールを定義するものです。
arrayStride
は、1つの頂点を表すデータがバッファ内でどれくらいのバイト数を占めるかを指定します。
この指定により、たとえば2番目の頂点を取り出すには、バッファの先頭からarrayStride
バイトだけ進んだ位置から読み出せばよいことになります。
vertices
は、頂点座標データを格納したJavaScriptのFloat32Array
型の配列でした。
このBYTES_PER_ELEMENT
プロパティは、配列内の1つの数値が何バイトであるかを示すプロパティです。
ここでは、1つの頂点を2つの数値で表現しているため、2
をかけることで1つの頂点のデータが占めるバイト数を計算しています。
attributes
には、1つの頂点に含まれる属性のデスクリプタを配列としてまとめて指定します。
これで、頂点バッファ全体のデータの並びを定義することができました。
このレイアウトデスクリプタを、描画設定を一元管理するオブジェクトであるレンダーパイプラインに組み込むことで、シェーダは頂点バッファのデータを正しく読み取ることができるようになります。
const renderPipeline = device.createRenderPipeline({
layout: "auto",
vertex: {
module: shaderModule,
entryPoint: "vs_main",
buffers: [
vertexBufferLayout // 頂点データのレイアウトデスクリプタを指定
]
},
fragment: {
// ...
}
})
ここまでの説明では、役割を明確にするためにpositionAttribute
やvertexBufferLayout
を変数として定義してきましたが、わざわざ変数にせず、次のようにレンダーパイプラインに直接書き込んでしまってもよいでしょう。
const renderPipeline = device.createRenderPipeline({
layout: "auto",
vertex: {
module: shaderModule,
entryPoint: "vs_main",
buffers: [
{
arrayStride: vertices.BYTES_PER_ELEMENT * 2,
attributes: [
{
shaderLocation: 0,
offset: 0,
format: "float32x2"
}
]
}
]
},
fragment: {
// ...
}
})
頂点バッファを使って描画するように設定
最後に、レンダーパスで頂点バッファを使って描画するように設定します。
renderPass.setPipeline(renderPipeline)
renderPass.setVertexBuffer(0, vertexBuffer)
renderPass.draw(3)
renderPass.end()
setVertexBuffer()
メソッドの第一引数として渡している0
は、renderPipeline.vertex.buffers
配列のうち、0
番目のバッファを使うことを意味しています。
実装コード全文:頂点バッファを使った三角形の描画
これでコードは完成です。実行結果は前回と同じになりますが、コードの柔軟性が向上しました。
ハイライトされた変更箇所に注目して、コード全文を確認してみてください。
struct VertexInput {
@location(0) position: vec2f
};
struct VertexOutput {
@builtin(position) Position: vec4f
};
@vertex
fn vs_main(in: VertexInput) -> VertexOutput {
var out: VertexOutput;
out.Position = vec4f(in.position, 0.0, 1.0);
return out;
}
@fragment
fn fs_main() -> @location(0) vec4f {
return vec4f(0.918, 0.561, 0.918, 1.0);
}
const adapter = await navigator.gpu.requestAdapter()
const device = await adapter.requestDevice()
const canvas = document.querySelector("canvas")
const context = canvas!.getContext("webgpu")
const canvasFormat = navigator.gpu.getPreferredCanvasFormat()
context.configure({ device, format: canvasFormat })
const vertices = new Float32Array([0.0, 0.5, -0.5, -0.5, 0.5, -0.5])
const vertexBuffer = device.createBuffer({
size: vertices.byteLength,
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST
})
device.queue.writeBuffer(vertexBuffer, 0, vertices)
const shaderModule = device.createShaderModule({ code: shaderCode })
const renderPipeline = device.createRenderPipeline({
layout: "auto",
vertex: {
module: shaderModule,
entryPoint: "vs_main",
buffers: [
{
arrayStride: vertices.BYTES_PER_ELEMENT * 2,
attributes: [
{
shaderLocation: 0,
offset: 0,
format: "float32x2"
}
]
}
]
},
fragment: {
module: shaderModule,
entryPoint: "fs_main",
targets: [
{
format: canvasFormat
}
]
}
})
const commandEncoder = device.createCommandEncoder()
const renderPass = commandEncoder.beginRenderPass({
colorAttachments: [
{
view: context.getCurrentTexture().createView(),
loadOp: "clear",
clearValue: { r: 0.749, g: 0.925, b: 1.0, a: 1 },
storeOp: "store"
}
]
})
renderPass.setPipeline(renderPipeline)
renderPass.setVertexBuffer(0, vertexBuffer)
renderPass.draw(3)
renderPass.end()
device.queue.submit([commandEncoder.finish()])
実行結果は次のようになります。
Next Step
Deep Dive
-
今回登場した
Float32Array
などの型付き配列や、ArrayBuffer
との関係について詳しく学びたい方におすすめの記事です。 -
Linuxの仕組みに関する記事ですが、「マッピング」のイメージを掴む上で参考になるかもしれません。