3D RENDERING HISTORY
CHAPTER 06 · TEXTBOOK

第6章 WebGPUでレイトレーシングを並列化する

目的:第5章でCPUに書いたレイと球・平面の解析的交差判定を、WGSLのCompute Shaderへ移す。GPUで画素ごとにレイを計算し、影・鏡面反射の二次レイまで追う。 WebGPUの実験 / 第5章・CPUのレイトレーシングとSDF / 第4章・OpenGLとWebGL 順序:第3章Z-bufferは引き続き企画中。この章は「ラスタライズとは別の描画経路」として学ぶ発展編。

1. CPU版と何が変わるのか

第5章は、球2個と床についてJavaScriptのループで1画素ずつ交差を求めていた。今回も同じ位置と大きさの球・床を使うが、処理をGPUの多数のスレッドへ移す。

ここで使うのはハードウェア専用レイトレーシング命令ではない。 WebGPUの標準的なCompute Shaderで球の二次方程式を手書きし、複数スレッドで実行している。BVH、専用RTパイプライン、RTX/DXRのアクセラレーション構造は使わない。

2. 1画素から1本のレイを作る

カメラの原点 \(\mathbf{o}\) と単位方向 \(\mathbf{d}\) を使うレイの式はCPU版と共通。

\[ \mathbf{p}(t)=\mathbf{o}+t\mathbf{d},\qquad t\ge 0 \]

第5章と同じ「カメラは+Zを見る」規約で、原点は \((0,0.15,-4.5)\)、垂直視野角は55度。画素中心、縦横比と画角から方向を正規化する。

球の中心 \(\mathbf{c}\)、半径 \(r\) なら、

\[ \|\mathbf{o}+t\mathbf{d}-\mathbf{c}\|^2=r^2 \]

として \(t\) の二次方程式を解く。床は平面 \(y=-1.26\) として直接交差距離を求める。最小の正の距離を採用するので、z-bufferがなくても最前面を求められる。

この実装での「ray tracing」は三角形のラスタライズではなく、解析的なray–object intersection。大規模なメッシュの交差判定や加速構造は扱わない。

3. Compute Shaderで画素を分担する

WGSLのエントリーポイントは以下のように始まる。

@compute @workgroup_size(8, 8, 1)
fn tracePixels(@builtin(global_invocation_id) pixelIndex: vec3u) {
  if (pixelIndex.x >= cameraParams.dimensions.x ||
      pixelIndex.y >= cameraParams.dimensions.y) {
    return;
  }
  // 各画素で1本のレイを生成し、色を求める
  let rayDirection = /* 正規化した画素方向 */;
  let outputColor = castRays(cameraOrigin, rayDirection);
  textureStore(gpuImage, vec2i(pixelIndex.xy), vec4f(outputColor, 1.0));
}

このコードは構造の抜粋であり、実行可能な完全コードは raytrace.wgsl にある。画素座標は左上原点で扱い、レンダラ側でY反転を重複実施しない。

8×8 = 64スレッドのワークグループを、たとえば384×240なら48×30個起動する。

pass.dispatchWorkgroups(Math.ceil(width / 8), Math.ceil(height / 8));

ワークグループを「CPUコア数」と同一視してはいけない。実際の並列スケジューリングはGPUとドライバーに任せられる。

4. 影と反射レイ

第5章の一次レイは最初に交差する面だけを求めていた。交点 \(\mathbf{p}\)、法線 \(\mathbf{n}\)、入射方向 \(\mathbf{d}\) が得られれば、次のレイも飛ばせる。

鏡面反射の方向は

\[ \mathbf{r}=\mathbf{d}-2(\mathbf{d}\cdot\mathbf{n})\mathbf{n} \]

で、WGSLでは reflect(currentDirection, normalVector) と書く。反射回数が1以上なら、その方向で再び最前面の交点を求める。再帰呼び出しではなく、最大4回の反復ループで一次レイ+最大3回の反射レイを追う。

影用レイは表面から照明の方向へ飛ばす。途中で別の物体に当たると、簡易照明の直接光を暗くする。

let shadowOrigin = surfacePoint + normalVector * 0.004;
let shadowHit = closestIntersection(shadowOrigin, sunDirection);
if (shadowHit.surfaceIndex >= 0) {
  illumination = 0.20 + 0.16 * lambertFactor;
}

0.004 は浮動小数点誤差による自己交差(shadow acne)を軽減するためのオフセットで、すべての縮尺に通用する値ではない。

注意:本教材の反射は鏡面反射の単純な反復であり、屈折・微小面分布・確率サンプリング・間接拡散光を扱うパストレーシングではない。

5. GPUへ値を渡す

CPUは描画設定を32バイトのUniform Bufferに詰める。

| 項目 | オフセット | 型 | |---|---:|---| | 幅と高さ | 0 | vec2u | | カメラYaw | 8 | f32 | | 最大反射回数 | 12 | u32 | | 影のON/OFF | 16 | u32 | | 露光係数 | 20 | f32 | | 予約領域 | 24 | vec2u |

WGSL構造体のアラインメントに合わせて、JavaScriptのDataViewで明示的に書き込む。

Compute Shaderは rgba8unorm ストレージテクスチャへ書き込み、表示側のフラグメントシェーダーはそのテクスチャから画素を読み出す。一般的なCanvasの bgra8unorm を直接ストレージ書込先にする場合は追加要件があり得るため、Compute出力と画面表示先は分離している。

6. 手順と実験

実験を開く。WebGPUの利用にはHTTPSまたは安全とみなされるローカル環境と、対応ブラウザ・GPUが必要。非対応なら理由を表示し、CPUに無言で切り替えない。

  1. 反射を0回にする。一次レイだけの結果を観察する。
  2. 反射を1→2→3回と増やし、球・床に反射した色の変化を見る。
  3. 影をOFFにして、二次レイによる遮蔽の表現を比較する。
  4. Yawと解像度を変えて画像と処理完了までの経過時間を比較する。
  5. 第5章のCPU版と球の輪郭を比べる。第6章は反射・影を追加しているので処理時間の単純な性能比較にはならない。

画面の時間表示はqueue.writeBufferからqueue.onSubmittedWorkDone()までの経過時間で、CPU待機・GPUキューも含まれる。GPU単独の実行時間を測るTimestamp Queryではない。

7. テストと限界

GitHub ActionsのヘッドレスChromeではSwiftShader/Vulkanを明示してGPU処理をテストする。ページを開いただけでは成功にせず、Adapter・Deviceの取得、WGSLコンパイル、Compute/Render Pipeline生成、drawとdispatch、validation scope、queue.onSubmittedWorkDone()、オフスクリーンのピクセルreadbackまで検査する。

CIの描画先だけをオフスクリーンテクスチャへ変更する。ComputeとRenderのWGSL・パイプライン・バインドグループは実際の表示と同じ。これにより、ヘッドレス環境でCanvasのSwapchainが作れないケースと、WGSL自体のエラーを分離する。

主な限界:

8. 演習と参考資料

仕様: WebGPU API(MDN) / WGSL仕様(W3C) / WebGPU仕様(W3C)。

実装原本:raytrace.wgsl、present.wgsl、gpu-engine.js、app.js。GPUなしのテストではCPUの疑似描画を「GPU成功」と呼ばない。

実際に立方体を操作するMarkdown原本