はじめに
こんにちは!最近崩壊スターレイルを初めて全然勉強できていませんw
前回の記事でWebGPUの環境構築を行ったので、今回はグラフィックスAPIのチュートリアルでおなじみの三角形ポリゴンを描画しようと思います!
また、前回同様間違っていることがありましたら指摘していただくと嬉しいです。
ソースコードは一部をちょっとずつ説明していますが、全体を見たいからはGitHubにあげているのでそちらを見てください!
HTML
まずはindex.htmlを変更しましょう。
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<body>
    <div>
        <canvas id="gfx-main" width="800" height="600"></canvas>
    </div>
    <script src="dist/main.js"></script>
</body>
</html>
bodyにcanvasタグを追加します。idも追加しましょう。今回はgfx-mainにしておきます。
TypeScript
次にmain.tsを変更していきます。
やることが多いので段階に分けてやっていきます。
まずは初期化関数を定義し実行させます。
async function Init() {
    
}
Init();
WebGPUで使用する関数で非同期処理を行いたいためasync関数として定義します。
では初期化処理を書いていきしょう。
async function Init() {
    const adapter : GPUAdapter = <GPUAdapter> await  navigator.gpu?.requestAdapter();
    const device : GPUDevice = <GPUDevice> await adapter?.requestDevice();
}
まずはアダプターとデバイスを要求します。
アダプターは navigator.gpu?.requestAdapter()で要求することができます。これはPromise を返すのでawiteを宣言しておきます。
また適切なアダプターが見つからない場合があります。これはWebGPUに対応していないGPUを使っていると発生する場合があります。そのため?.というオプショナルチェイニング演算子を使ってアダプターがnullだった場合は関数にアクセスしてもエラーではなくundefiendを吐くようにしてくれる便利な演算子です。
本来はnullチェックなどして動作環境をしっかり管理するのだと思いますが、今回はめんどくさいのでしません!コードも冗長になるので!!!
次に今作ったアダプターを使ってデバイスを要求します。
adapter?.requestDevice()でデバイスを要求できます。このデバイスは今後いろいろな場面で使うことになるものです。覚えておいてください!
次はindex.htmlで宣言したcanvasタグを取得し設定していきます。
async function Init() {
   // 続き
    const canvas: HTMLCanvasElement = <HTMLCanvasElement> document.getElementById("gfx-main");
    const context : GPUCanvasContext = <GPUCanvasContext> canvas.getContext("webgpu");
    const format : GPUTextureFormat = "bgra8unorm";
    context.configure({
        device:device,
        format:format,
        alphaMode: "opaque"
    });
}
document.getElementById("gfx-main");でcanvasタグを取得します。このcanvasからcanvasContextを取得します。今回はWebGPUなので"webgpu"を指定します。
次にこのContextにconfigureで設定を行います。
deviceには先ほど取得したデバイスを指定します。
formatには"bgra8unorm"もしくは"rgba8unorm"を指定するのが一般的なようです。今回はformatという変数を作って"bgra8unorm"を代入して使用しました。
alphaModeには"opaque"を指定します。
alphaModeはcanvasの背景との合成方法を指定する要素です。"opaque"はWebGPUで完全に上書きすることを指定します。
また、"premultiplied"を指定するとアルファ値が1未満の場合
背景の色と合成されます。
次にバインドグループを作成します。
async function Init() {
   // 続き
    const bindGroupLayout = device.createBindGroupLayout({
        entries: [],
    });
    const bindGroup = device.createBindGroup({
        layout: bindGroupLayout,
        entries: []
    });
}
バインドグループはTypeScriptで定義した配列や構造体などのデータをshader側に渡すことができます。データをshaderに渡す方法はもう一つあります。今回はシェーダー内で定義する予定なので空にしておきます。
今後データの渡し方やバインドグループについての記事も書こうと思います。
次にパイプラインを作成します。
そのためにシェーダーファイルをインポートする必要があるので空のシェーダーファイルを作っておきましょう。
src直下にshadersフォルダーを作成しshader.wgslをいうファイルを作りましょう。シェーダーは後程書きます。
root/
  ├ node_modules/
  ├ src/
+ │   ├ shaders/
+ │   │  └ shader.wgsl
  │   ├ types/
  │   │  └ shader.d.ts
  │   └ main.ts
  ├ index.html
  ├ package-lock.js
  ├ pakage.json
  ├ tsconfig.json
  └ webpack.config.js
shader.wgslファイルを追加したらmain.tsにインポートしましょう。Init関数の上側にインポートします。
import shader from './shaders/shader.wgsl'
async function Init() {
}
では準備が終わったのでパイプラインを作成していきます。
async function Init() {
   // 続き
    const pipelineLayout = device.createPipelineLayout({
        bindGroupLayouts:[bindGroupLayout]
    });
    const shaderModule = device.createShaderModule({
        code: shader
    });
    const pipeline = device.createRenderPipeline({
        vertex:{
            module: shaderModule,
            entryPoint: "vs_main"
        },
        fragment:{
            module: shaderModule,
            entryPoint: "fs_main",
            targets: [{
                format: format
            }]
        },
        layout: pipelineLayout
    });
}
パイプラインはシェーダーを動かすためのオブジェクトだと思います。まだ完全に理解しきっていないため説明が難しいので今回は流します……
WebGPUで使用できるシェーダーは頂点シェーダー、フラグメントシェーダー、コンピュートシェーダ―の3種類です。
頂点シェーダーは名前の通り描画する頂点を制御するシェーダーです。
フラグメントシェーダーは画面のピクセルごとに描画する色を計算するシェーダーです。頂点シェーダーで描画する形を決めたのでそれをどのような色にするかを決める感じです。
コンピュートシェーダはGPUで数値計算をするためのシェーダーでいわゆるGPGPUをやるためのものです。自分は主にこれを使っていろいろやってたので他の二つよりはほんの少し詳しいです。
今回は数値計算を行う必要がないので前者の2つを使っていきます。
ではパイプラインについて説明していきます。
まずはパイプラインレイアウトをdevice.createPipelineLayout()で作成します。これで先ほど作成したバインドグループレイアウトとパイプラインレイアウトを紐づけます。バインドグループで作成したリソースのメモリをまずは確保しているのだと思います。
次にdevice.createShaderModule()を使ってシェーダーモジュールを作成します。引数には先ほどインポートしたシェーダーを指定します。こうすることで引数のcodeにシェーダーのソースコードが展開されます。それをdevice.createShaderModule()でシェーダーを実行可能な形にしているのだと思います。公式ドキュメントに詳しいことが書かれていなかったので憶測になりますが……
また、codeの部分には直接シェーダーのコードを書くことも可能なようです。ただ分かりにくくなると個人的には思ったので今回は分割しました。
最後にパイプラインをdevice.createRenderPipeline()で作成します。これはいろいろと設定を行う必要があります。
まずはvertexを設定します。これは頂点シェーダのについての項目です。先ほどのモジュールとエントリーポイントの名前を文字列で渡します。今回のエントリーポイントはVertex Shaderのメイン関数ということでvs_mainとしておきます。
次にfragmentを設定します。これはフラグメントシェーダーについての項目です。vertexとほぼ同じです。エントリーポイントはfs_mainとしておきます。targetsはフラグメントシェーダーが出力する色についての設定を行えます。今回はformatのみ設定しておきます。canvasのcontextの設定で使用したformatを流用しています。
最後にlayoutは先ほど作成したパイプラインレイアウトを指定しましょう。これでパイプラインの設定は終わりです。
TypeScriptだけでもかなり長くなりましたね……
次のオブジェクトの設定で最後です!
次はレンダーパスを作成していきます。これはGPUでの命令であるコマンドを発行するためのオブジェクトです。
これで発行したコマンドをGPUに送ることでGPUへの命令を実行することができます。
async function Init() {
   // 続き
    const commandEncoder : GPUCommandEncoder = device.createCommandEncoder();
    const textureView : GPUTextureView = context.getCurrentTexture().createView();
    const renderpass : GPURenderPassEncoder = commandEncoder.beginRenderPass({
        colorAttachments: [{
            view: textureView,
            clearValue: {r: 0.5, g: 0.5, b: 0.5, a: 1.0},
            loadOp: "clear",
            storeOp: "store"
        }]
    });
    renderpass.setPipeline(pipeline);
    renderpass.setBindGroup(0, bindGroup);
    renderpass.draw(3, 1, 0, 0);
    renderpass.end();
    device.queue.submit([commandEncoder.finish()]);
}
まずはコマンドエンコーダーをdevice.createCommandEncoder()で作成します。これはのちの発行するコマンドをエンコードするためのオブジェクトです。
次にテクスチャービューを作成します。これはWebGPUで描画するバッファー、つまり描画する画面を指します。今回はcanvasのcontextのバッファを取得する必要があるのでcontext.getCurrentTexture().createView()で取得します。
次にレンダーパスを取得します。これはcommandEncoder.beginRenderPass()で取得できます。またこれはGPUへのコマンドを順番に実行すて行くのですが、一番最初に背景の塗りつぶしを行うことができるので行いましょう。
colorAttachmentsに初期設定を行います。まずはviewwは描画する画面です。先ほどのテクスチャービューを指定します。
clearValueには色をrgba方式で指定します。今回の設定では灰色になっているはずなのでお好きな色で設定してみてください!
loadOpはこの初期化を行う前に行う処理を書きます。"clear"を選んでおきましょう。設定した色で初期化されません。
storeOp箱の初期化処理を行った後の処理を書きます。"store"を選んでおきましょう。設定した色が破棄されます。
これでレンダーパスを生成できました。次にレンダーパスを使ってコマンドを発行していきましょう。
まずはrenderpass.setPipeline(pipeline)でパイプラインをセットします。
パイプラインをセットしたら次にrenderpass.setBindGroup(0, bindGroup)でバインドグループをセットします。第一引数の数値はインデックスです。詳しくは別の記事で描こうと思います。今回は0で大丈夫です!
次に描画コマンドをrenderpass.draw(3, 1, 0, 0)で発行します。
第一引数は描画する頂点数です。今回は三角形の描画なので3です。
第二引数は描画するインスタンス数です。今回は一つの三角形なので1です。
第三引数は最初に描画する頂点を指定します。こだわりはないので0です。
第四引数は最初に描画するインスタンスを指定できます。これも0でいいです。
最後にこれらのコマンドを持ったcommandEncoderをdevice.queue.submit()でGPUに送ります。
これでTypeScriptのコードは終わりです!お疲れ様でした。
WGSL
では最後にシェーダーを書きましょう!TypeScriptに比べると短いので頑張りましょう!
WGSLとはWebGPU用のシェーダー言語です。構文はRustに近い言語になっています。
まずは頂点シェーダーの出力に必要な構造体を定義します。
struct Fragment {
    @builtin(position) Position : vec4<f32>,
    @location(0) Color : vec4<f32>
};
@builtin()はWGSLで定義済みの内部変数を使用することを意味しています。
つまり@builtin(position) Position : vec4<f32>はWGSLで定義済みの変数であるpositionをPositionという名前でvec4<f32>という方で使うということを言っています。vec4は4つの要素を持つ構造体だと思ってください。
@location(0)はTypeScript側で指定したフォーマットのどれに対応するかを示しています。
以下の二つの色が付いている部分に対応する番号を振る必要があります。
    const pipeline = device.createRenderPipeline({
        vertex:{
            module: shaderModule,
            entryPoint: "vs_main"
        },
        fragment:{
            module: shaderModule,
            entryPoint: "fs_main",
+           targets: [{
+               format: format
+           }]
        },
        layout: pipelineLayout
    });
    const renderpass : GPURenderPassEncoder = commandEncoder.beginRenderPass({
+       colorAttachments: [{
+           view: textureView,
+           clearValue: {r: 0.5, g: 0.5, b: 0.5, a: 1.0},
+           loadOp: "clear",
+           storeOp: "store"
+       }]
    });
二つとも一つしかないため今回はというか基本0で大丈夫です!
描画領域が二つとかのときに使うものだと思います。多分……
次は頂点シェーダーを書いていきましょう。
@vertex
fn vs_main(
    @builtin(vertex_index) v_id: u32
) -> Fragment {
    var positions = array<vec2<f32>, 3> (
        vec2<f32>( 0.0,  0.5),
        vec2<f32>(-0.5, -0.5),
        vec2<f32>( 0.5, -0.5)
    );
    var colors = array<vec3<f32>, 3> (
        vec3<f32>(1.0, 0.0, 0.0),
        vec3<f32>(0.0, 1.0, 0.0),
        vec3<f32>(0.0, 0.0, 1.0)
    );
    var output : Fragment;
    output.Position = vec4<f32>(positions[v_id], 0.0, 1.0);
    output.Color = vec4<f32>(colors[v_id], 1.0);
    return output;
}
では順にみていきましょう。
@vertex
fn vs_main(
    @builtin(vertex_index) v_id: u32
) -> Fragment {
エントリーポイントはパイプラインのときに設定したvs_mainです。エントリーポイントの上には@vertexと書きます。これは頂点シェーダーであるということを示しています。
@builtin(vertex_index)は割くほど説明した内部変数を使っています。vertex_indexは頂点座標の配列からインデックスを取得しています。
関数の引数の後ろについている-> Fragmentは返り値の型を示しています。これはRustでの書き方ですね。
    //略
    var positions = array<vec2<f32>, 3> (
        vec2<f32>( 0.0,  0.5),
        vec2<f32>(-0.5, -0.5),
        vec2<f32>( 0.5, -0.5)
    );
    var colors = array<vec3<f32>, 3> (
        vec3<f32>(1.0, 0.0, 0.0),
        vec3<f32>(0.0, 1.0, 0.0),
        vec3<f32>(0.0, 0.0, 1.0)
    );
    
    var output : Fragment;
    output.Position = vec4<f32>(positions[v_id], 0.0, 1.0);
    output.Color = vec4<f32>(colors[v_id], 1.0);
    return output;
ここは三角形の頂点位置と色を決めている部分です。
positionで位置をcolorで色を決めています。
それをFragmentに代入して出力しています。
次にフラグメントシェーダーを見ます。
//頂点シェーダーの下に書く
@fragment
fn fs_main(
    @location(0) Color: vec4<f32>
) -> @location(0) vec4<f32> {
    return Color;
}
このフラグメントシェーダーは頂点シェーダーで設定した色設定を受け取り、そのまま返すものです。
頂点シェーダーから受け取るために@locationにはFragment構造体設定した番号を設定します。
これでソースコードは終わりです!お疲れさまでした!
実行
それではこれまでのコードを実行しましょう!
サーバーを立てます。前回の環境構築と同じようにコマンドを登録した方は以下のコマンドをターミナルに入力します。
npm start
しばらくしてサーバーが立ち上がると自動でブラウザが開くと思います。もしchrome以外の場合はchrome
で立ち上がったブラウザのURLに行ってください。
以下のように表示されているたら成功です!

最後に
次はシェーダーにデータを送る記事を書こうと思います。
その後は何も考えていませんがコンピュートシェーダについてもやりたいですね~
グダグダやっていく予定ですがこれからも見てくれる方がいればうれしいです!ここまで見てくださりありがとうございました!
参考
