はじめに
この記事はHIKKYアドベントカレンダー2023の10日目の記事です。
こんにちは、 @emadurandal と申します。HIKKYのエンジン開発部でメタバースエンジンの開発に従事しています。
今回は、WebGLからWebGPUへのステップアップについての記事を書いてみようと思います。
なぜWebGPUが登場したのか
WebGLはOpenGL ESのAPI体系をブラウザに移植したものです。
そのOpenGLですが、歴史的経緯により、GPUやCPUの性能を完全に引き出しきれないレガシーな部分を引きずっていました。
詳しくはこちらの記事をご覧ください。
そのため、WebGL2の次はWebGL3というわけにはいかなかったようです。
よりGPUの性能を引き出せる、よりモダンなAPI体系が必要でした。ネイティブにはVulkan APIがありますが、それをそのままブラウザに持ってくるには、セキュリティの問題や扱いやすさなどの課題から、問題も多くありました。
そこで、Metal、DirectX12、Vulkanそれぞれの特徴をうまくまとめ、その共通項となるAPIをW3Cが中心となって策定することになりました。それがWebGPUです。
WebGPUの新しい特徴と、WebGLとの主な違い
(WebGLに比べて)GPUのモダンな機能が利用できる
Storage Bufferが使える
WebGL2からUBO(Uniform Buffer Object、DirectXでいうConstant Buffer)が使えましたが、UBOもサイズには制約があり、より広大なメモリデータをシェーダーで扱うには浮動小数点テクスチャにデータを格納する必要がありました。
WebGPUでは新たにStorage Bufferというより広大なGPUバッファがサポートされました。これにより、シェーダーで広大なメモリデータを簡単に扱うことができるようになります。
このStorage Bufferはフラグメントシェーダーと後述するコンピュートシェーダーからなら書き込みも可能です。これにより、WebGPUではGPUを汎用的な計算機として利用しやすくなりました。
Reversed-Zが使える
GPUを用いたリアルタイムレンダリングにおいては、近景から遠景までスムーズに物体を描画するため、Zバッファの精度が重要になってきます。Zバッファの精度が足りないと、奥行きの近い物体同士がチラチラしてしまう、いわゆるZファイティング現象が起こることがあります。
このZバッファですが、[0,1]の値域を逆転させることで、Zテストの精度が高まることがわかっています。これがReversed-Zテクニックです。
Reversed ZとZバッファの精度についての詳細は、NVIDIA社のこちらの記事をご覧ください。
https://developer.nvidia.com/content/depth-precision-visualized
残念ながら、WebGLではビューのNearとFarのクリッピングプレーンを逆転させることがAPI的にサポートされていません。また、正規デバイス座標系(NDC)のZの値域もWebGLは他のAPIと違い[-1,1]という独特なものになっており、これも精度のために[0,1]に変更する必要があるのですが、それもできません。
つまり、WebGLではReversed-Zテクニックが使えませんでした。
WebGPUでは全く問題なくReversed-Zテクニックを使うことができます。詳しくはこちらのサンプルをご覧ください。
WebGLより高速な動作を実現するためのAPI設計になっている
WebGLはステートフルなAPIでした。つまり、さまざまなAPI設定の状態をレンダリングコンテキストに覚え込ませ、その記憶している状態に動作が依存することを意味します。
これは、実行レイヤーの実装が複雑になるデメリットをもたらします。
また、複数のアプリケーションが同じレンダリングコンテキストを共有して利用するなどの場合、互いの変更が競合し合い、デバッグが困難になります。
WebGPUは最近の低レベルAPIと同様、ステートレスなAPIです。ステートレスなため、以前にどのような設定がされていたかを気にかける必要はありません。プログラマはGPUに対してコマンドを積み、パイプライン設定をセットし、リソースのバインディングを設定します。
GPUがネイティブに扱うデータの設定方法とほぼ同じ仕組みのAPI体系となっており、これらは高速に動作します。
Compute Shaderが使える
WebGLからWebGPUへのジャンプで、一番の利点と言えるかもしれません。
Compute Shaderがサポートされ、GPUを汎用計算に使えるようになりました。早速、TensorFlow.jsなどがWebGPUのCompute Shaderを活用するように対応を始めているようです。
これで、Web上でのAI推論などもより実用的になっていくでしょう。
今後も機能増強が予定されている
WebGLひいては元となっているOpenGL ES/OpenGLはすでにその進化を止め、新たな機能追加は後継APIであるVulkanに対して行われています。つまり、WebGLにも今後、大型の機能追加はないと考えられます。
(尤も、WebGL2に関しては、細々とした拡張機能については今でも追加されてはいます)
WebGPUについては、今後Wave Intrinsicsやレイトレーシング対応など、将来のさまざまな機能追加が議論されています。今後も成長が楽しみなAPIだと言えるでしょう。
WebGPUで苦労すること
コード変換と互換性の問題
WebGLからWebGPUにステップアップする際、戸惑うのはシェーダー言語が異なることです。
WebGLではGLSLを使いましたが、WebGPUではWGSL(WebGPU Shading Language)という独自のシェーダー言語を使います。
これまでGLSLの資産を多く持っていたプログラマは、戸惑うと思います。これらのコード資産をどうWGSLに対応させるべきか。
一つの案は、シェーダー変換ツールを使うことです。Google TintやNagaなどの変換ツールで、GLSLをWGSLに変換することができます。ただし、 WGSLに変換するにあたり、GLSL側の記述で情報が足らないことがあり、スムーズに変換できないこともあります。
最後は、愚直に手作業でWGSLに変換することも検討に入れる必要があるでしょう。
パフォーマンス調整と最適化の違い
WebGPUなのに遅い? WebGPUでは必要なデータをGPUメモリに全て展開する必要あり
WebGLでは、例えば100個の物体を表示する際、物体の位置を示す情報(例えばモデル行列)を一つ分、Uniform変数またはUBOに持たせ、各物体の描画命令の直前に、そのモデル行列を更新すれば問題ありませんでした。
しかし、WebGPUで同じやり方をすると、むしろWebGLより遅くなります。
WebGPUで高速に描画するには、物体100個分のモデル行列をGPUメモリ上にあらかじめ確保しておき、最初に全て一気に更新する必要があります。
詳しくは私のこちらの記事をご覧ください。
このような描画方法にするには、レンダリング周りのコード設計を大幅に見直す必要に迫られるかもしれません。WebGLからWebGPUへの移行は、そうした面も含めると意外と大変です。
WebGPUのUniform Control Flow制約とは?
WebGPUのシェーダー言語WGSLでは、これまでのシェーダー言語の常識では思いもよらない、少々厳しい制約が存在します。それがUniform Control Flowです。
手短に説明すると、シェーダーコードを通る全てのスレッドは、「一様な」制御フロー(同じ分岐、同じ繰り返し数)を通らなければならない、ということです。
厳密には、非一様なフローも認められていますが、その制御フローエリア内では厳しい制約が存在します。
コード例を示すと、以下のようなコードはWGSLでは認められません。
@group(0) @binding(1) var myTexture: texture_2d<f32>;
@group(0) @binding(2) var mySampler: sampler;
@fragment
fn main(
@location(0) fragUV: vec2<f32>,
) -> @location(0) vec4<f32> {
var tex = textureSample(myTexture, mySampler, fragUV);
if (tex.r > 0.5) {
tex = textureSample(myTexture, mySampler, fragUV).bgra; // Error!
}
return tex;
}
この例では、if文の中が非一様な制御フロー(動的なテクスチャサンプルの結果であるtexの値によって分岐を行っているため)であり、その中でのテクスチャサンプルはWGSLでは認められていないからです。
エラーを回避するには、次のようにする必要があります。
@group(0) @binding(1) var myTexture: texture_2d<f32>;
@group(0) @binding(2) var mySampler: sampler;
@fragment
fn main(
@location(0) fragUV: vec2<f32>,
) -> @location(0) vec4<f32> {
let color = vec4f(1.0, 0.0, 0.0, 1.0);
var tex = textureSample(myTexture, mySampler, fragUV);
let tex2 = textureSample(myTexture, mySampler, fragUV).bgra;
if (tex.r > 0.0) {
tex = tex2;
}
return tex;
}
このUniform Control Flow制約は、GPUパフォーマンス最適化や、各GPUでの実行結果の同一性などを確保するための措置ということらしいのですが、シェーダー技法の中には、この制約では実装がやりにくいケースもあると思われます。
特に、マルチプラットフォームのアプリケーションやエンジンなどは、GLSLやHLSLで書かれているコード資産をWGSLに移植する際に、この制約にぶつかる可能性があり、回避するのに苦労するかもしれません。
Mipmapは自分で作れ? generateMipmap関数なんてものはない
WebGLでは存在したテクスチャのミップマップ作成関数(gl.generateMipmaps)ですが、WebGPUには相当する機能は存在しません。自分で処理を書いて対応する必要があります。面倒ですね。
別に用意してくれたっていいじゃん。と思いますよね。実際にそういう意見も上がっているようなのですが、議論の様子を見ると、今後も用意されることは望み薄のようです。
ここで途方にくれる方もいらっしゃると思うので、ここにWebGPUでミップマップを作成するコードを以下に示します。以下のtoji(Brandon Jones)氏の記事のコードを参考に、私が2Dテクスチャだけでなくキューブテクスチャにも対応するように拡張したものです。
https://toji.dev/webgpu-best-practices/img-textures#generating-mipmaps
ご自由にお使いください。
/**
* create a WebGPU Texture Mipmaps
*
* @remarks
* Thanks to: https://toji.dev/webgpu-best-practices/img-textures#generating-mipmaps
* @param texture - a texture
* @param textureDescriptor - a texture descriptor
* @param depthOrArrayLayers - depth or array layers
*/
generateMipmaps(
texture: GPUTexture,
textureDescriptor: GPUTextureDescriptor,
depthOrArrayLayers: number
) {
const gpuDevice = this.__webGpuDeviceWrapper!.gpuDevice;
const mipmapShaderModule = gpuDevice.createShaderModule({
code: `
var<private> pos : array<vec2f, 4> = array<vec2f, 4>(
vec2f(-1, 1), vec2f(1, 1),
vec2f(-1, -1), vec2f(1, -1));
struct VertexOutput {
@builtin(position) position : vec4f,
@location(0) texCoord : vec2f,
};
@vertex
fn vertexMain(@builtin(vertex_index) vertexIndex : u32) -> VertexOutput {
var output : VertexOutput;
output.texCoord = pos[vertexIndex] * vec2f(0.5, -0.5) + vec2f(0.5);
output.position = vec4f(pos[vertexIndex], 0, 1);
return output;
}
@group(0) @binding(0) var imgSampler : sampler;
@group(0) @binding(1) var img : texture_2d<f32>;
@fragment
fn fragmentMain(@location(0) texCoord : vec2f) -> @location(0) vec4f {
return textureSample(img, imgSampler, texCoord);
}
`,
});
const pipeline = gpuDevice.createRenderPipeline({
layout: 'auto',
vertex: {
module: mipmapShaderModule,
entryPoint: 'vertexMain',
},
fragment: {
module: mipmapShaderModule,
entryPoint: 'fragmentMain',
targets: [
{
format: textureDescriptor.format,
},
],
},
primitive: {
topology: 'triangle-strip',
stripIndexFormat: 'uint32',
},
});
const sampler = gpuDevice.createSampler({ minFilter: 'linear' });
const commandEncoder = gpuDevice.createCommandEncoder({});
for (let j = 0; j < depthOrArrayLayers; ++j) {
let srcView = texture.createView({
baseMipLevel: 0,
mipLevelCount: 1,
baseArrayLayer: j,
});
for (let i = 1; i < textureDescriptor.mipLevelCount!; ++i) {
const dstView = texture.createView({
baseMipLevel: i,
mipLevelCount: 1,
baseArrayLayer: j,
});
const passEncoder = commandEncoder.beginRenderPass({
colorAttachments: [
{
view: dstView,
loadOp: 'load',
storeOp: 'store',
},
],
});
const bindGroup = gpuDevice.createBindGroup({
layout: pipeline.getBindGroupLayout(0),
entries: [
{
binding: 0,
resource: sampler,
},
{
binding: 1,
resource: srcView,
},
],
});
// Render
passEncoder.setPipeline(pipeline);
passEncoder.setBindGroup(0, bindGroup);
passEncoder.draw(4);
passEncoder.end();
srcView = dstView;
}
}
gpuDevice.queue.submit([commandEncoder.finish()]);
}
/**
* create a WebGPU Texture
* @param imageData - an ImageBitmapData
* @param paramObject - a parameter object
* @returns
*/
public createTextureFromImageBitmapData(
gpuDevice: WebGPUDevice,
imageData: ImageBitmapData,
{
level,
internalFormat,
width,
height,
border,
format,
type,
generateMipmap,
}: {
level: Index;
internalFormat: TextureParameterEnum;
width: Size;
height: Size;
border: Size;
format: PixelFormatEnum;
type: ComponentTypeEnum;
generateMipmap: boolean;
}
): WebGPUTexture {
const textureDescriptor: GPUTextureDescriptor = {
size: [width, height, 1],
format: 'rgba8unorm',
usage:
GPUTextureUsage.TEXTURE_BINDING |
GPUTextureUsage.COPY_DST |
GPUTextureUsage.RENDER_ATTACHMENT,
};
if (generateMipmap) {
textureDescriptor.mipLevelCount = Math.floor(Math.log2(Math.max(width, height))) + 1;
}
const gpuTexture = gpuDevice.createTexture(textureDescriptor);
gpuDevice.queue.copyExternalImageToTexture({ source: imageData }, { texture: gpuTexture }, [
width,
height,
]);
if (generateMipmap) {
this.generateMipmaps(gpuTexture, textureDescriptor, 1);
}
return gpuTexture;
}
未来への展望:WebGPUの可能性
WebGPUがWeb開発に与える影響
WebGPUは、初心の方からすると少しとっつきづらいAPIかもしれません。しかし、多くのライブラリがWebGPUに対応、対応を検討中のため、これらのライブラリの利用を通すことで、間接的にその恩恵を受けることができます。
3Dレンダリング分野では、Three.jsやBabylonJS、PlayCanvasなどのライブラリがWebGPU対応をしています。WebGPUで駆動することにより、Webでもより大規模な3Dコンテンツ・アプリケーションを楽しむことができるようになるでしょう。
また、TensorFlow.jsやONNXといった機械学習フレームワークがWebGPUに対応することで、Webブラウザでも高速なAI推論サービスの恩恵を受けることができるでしょう。
大多数の人は、こうしたライブラリを通じて恩恵を受けることになると思いますが、WebGPUはその下支えとなる実行レイヤーとして大活躍することでしょう。
ネイティブでもWebGPUが普及? WebGPUは皆が渇望した統一3D/GPGPU APIの夢を見るか
その昔、OpenGLとDirect3Dを統合しようというFahrenheitというAPIプロジェクトがありました。
https://en.wikipedia.org/wiki/Fahrenheit_(graphics_API)
結局はプロジェクトは頓挫したのですが、どんなプラットフォームでも使える、統一的な3D APIがあったら素敵だな、ということは誰しも思ったことがあるでしょう。
現在はVulkan, Direct3D, Metalと、3D APIがプラットフォームによって分化してしまって面倒くさい状況ですが、ここにきてWebGPUが統一3D APIに近いポジションを占めるのではないか、という見方もあるようです。
というのも、WebGPUはWebブラウザだけでなく、ネイティブ実装も存在します。
素晴らしいのは、有志の努力によりEmscriptenやGoogle Dawnなどで共通のWebGPUヘッダファイル(webgpu.hやwebgpu.hpp)が使われていることです。つまり、同じC++ソースコードからネイティブアプリケーションはもちろん、Emscriptenを通じたWeb 3Dアプリケーションも両方作成することができます。
WebGPU APIを利用するだけで、ネイティブ・Webほぼ全てのプラットフォームに対応した3D開発ができるということです。
Élie Michel氏によるC++でWebGPUを習うためのWebページもありますので、C++でWebGPUに挑戦してみたい方はぜひ参考にされてください。
WebGPUは各種APIの共通機能範囲内で策定されているため、全ての先端のGPU機能を使えるわけではありませんが、多くの人にとっては十分でしょう。
また、GPGPU分野では、どのプラットフォームでも安心して動作する単一のGPGPUプログラミングソリューションは残念ながらないといって良い状況でした。
そんな中、WebGPUのCompute Shaderなら、全てのプラットフォームでGPGPUできることを意味します。今後、どのプラットフォームでも動くGPGPUプログラミングの選択肢として、WebGPUを選択する人も出てきそうですね。
終わりに
本記事では、WebGPUの特徴や優位点、対応にあたってつまづくポイントなどを解説してきました。
WebGPUはWebプラットフォームに今時のモダンなGPU活用をもたらす、新世代のAPIです。
今後はレイトレーシング対応など、昨今のGPUトレンドも追いかけて開発議論が行われており、これからも進化が楽しみな技術分野と言えるでしょう。
ところどころ難所はありますが、今後のWeb3Dの未来は間違いなくWebGPUです。今のうちから少しずつ馴染んでいけるようにしたいですね。
ここまでお読みいただきまして、まことにありがとうございました。