WebGL2でCPUベースのレイトレーシングをやってみた

この記事はKAYAC Advent Calendar 2021の22日目の記事になります。

こんにちは!ハイカジチームの深澤と申します。

今回は実際の業務とは関係なく、自由研究的にやってみたことの記事になります。

数ヶ月ほど前に iOS15, macOS Monterey が登場し、ついに iOS / macOS ともに safari で WebGL2 が標準で使えるようになりました。この出来事はWebフロント界隈からするととても嬉しいお知らせでした。

そのことを思い出し、アドベントカレンダーでは WebGL2を使った何かを題材にしようと決め、前々からやってみたかった「ブラウザでCPUベースでレイトレをし3Dモデルを表示する」ことに取り組んでみたという経緯になります。

ここでいう「CPUベース」とは、「レイトレのピクセル計算をjavarcript側で行うこと」を指します。

目次

はじめに

今回の記事では、 ブラウザでCPUベースのレイトレを実装するにあたってどういう仕組みにしたか を中心に書いていきます。

なので、レイトレそのもの・コードベースでのレイトレについての記載は少なめになることをあらかじめご了承いただけますと幸いです。

また、ブラウザでのレイトレ自体はWebGL2であることが必須ではなく、結局本文でもWebGL2に関しての記述は少なくなりました。

自分が書いたレイトレ関連のプログラムは主に Ray Tracing in One Weekend — The Book Series を参考にさせていただきました。

できたもの

こちらになります。真ん中の宝石のような形のオブジェクトがOBJのデータになります。

f:id:takumifukasawa:20211221020914p:plain

今回「htmlファイル1つに全て書く」こともサブ目標にしていたので、アセットはhtml一個のみです。OBJのデータもテキストで埋め込みました。

また、ブラウザで行うのでインタラクションで絵が変わるようにしたいなと思い、Canvas上のマウス位置に応じてカメラ座標が変わり絵が更新されます。

f:id:takumifukasawa:20211221002757g:plain

綺麗な絵を出すこともやってみたかったので、初期サンプリング数ですべてのピクセルを描画し終わったらサンプリング数をさらに増やして各ピクセルを描画し直す流れを繰り返し、放っておいたら徐々に綺麗な絵に近づくようにしています。


動作デモとリポジトリはこちらになります。

動作デモ

https://takumifukasawa.github.io/js-raytracing-sandbox/for-article.html

リポジトリ

github.com

絵を出すアプローチ

グラフィクスAPI

まずレイトレーシングの前に、グラフィクスAPIを使ってなにかを表示する方法を振り返ってみます。グラフィクスAPIでは一般的にはラスタライズ法を用いてモノを表示させていきます。大雑把には以下のような流れになります。

頂点がどこの位置にあるかのデータを用意
↓
グラフィクスAPI経由でGPUに送る
↓
どういう形(点・線・面)で描画するかを設定
↓
頂点シェーダーを経由してスクリーン上のピクセル位置を決定する
↓
ピクセルシェーダーで色を決定

これを繰り返していくことで画面に絵が出力されるわけですね。

(イメージ図)

f:id:takumifukasawa:20211220223957p:plain

レイトレーシング

対してレイトレーシングは、レイという概念を使って色を計算していきます。レイは「どこから」「どこに向かって」という2つの情報を持っています。流れはざっくり以下のようになります。

視点から各ピクセルを通してシーン(どこに何があるかを管理するもの)に向かってレイを飛ばす
↓
何かに当たったら反射した先を探す
↓
また何かに当たったら反射した先を探す
↓
... 繰り返し ...
↓
どこかで追跡をやめ、反射情報を元に色を決定

(イメージ図)

f:id:takumifukasawa:20211220224632p:plain

疑似コードは例えばこのようになります。ループが続き、なんとなく計算量がとても多くなりそうですね。

for(let i = 0; i < height; i++) {
  for(let j = 0; j < width; j++) {
    // 各ピクセルの処理
    ...
    // ピクセルごとにサンプルする数。スーパーサンプリング
    for(let k = 0; k < sampleCount; k++) {
      // 最大追跡回数
      for(let l = 0; l < maxIterationCount; l++) {
        // rayを追跡
      }
    }
  }
}

あらためて、レイトレーシングはざっくりまとめると各ピクセルごとにレイを追跡して反射を繰り返す計算方法になります。現実の光で発生するような反射の繰り返しのシミュレーションに近くなるので、光の当たり方などがより自然になりやすいです。パストレーシングと呼ばれる手法ではもっと自然な見た目に近づいていきます。

この方法はグラフィクスAPIを使わなくても実現が可能です。今回はWebGL2を使いましたが、Canvasのcontext2dでも同じことができます。

擬似コードで簡単に触れたように、レイトレーシングで問題になるのは「計算時間」です。綺麗な絵を求めれば求めるほど反射の追跡回数やエイリアスの削減などで計算回数が膨大になります。

実際に作ったレイトレーサーの構成

もしシェーダーだけでレイトレをする場合、板ポリを1枚用意しシェーダーで各ピクセルごとにレイトレをする方法が考えられます。いわゆるレイマーチングによるシェーダー芸はこのやり方ですね。今回は各ピクセルの計算はjavascript側で行い、描画機能はWebGLに担当してもらいます。

オフスクリーンレンダリングで計算結果を溜めていく

javascript側で色の計算をしたのちに、何らかの方法を使ってWebGL経由でデータを渡し描画する必要があります。そこで、計算結果をオフスクリーンレンダリングでテクスチャに描画していき、そのテクスチャを画面に表示する流れをとります。

オフスクリーンレンダリングでは、FramebufferとTextureを用意します。Three.js,UE4では RenderTarget、Unityでは RenderTexture にあたるものですね。今回はThree.jsに寄せてオフスクリーンレンダリングの描画先のことを RenderTarget と呼ぼうと思います。

1. 一回で同時に計算するピクセル数分、座標と色情報を持つ頂点を生成
2. 同時に計算するピクセル数分、更新するピクセルを選択し、色を計算
3. 選択したピクセルの位置をクリッピング座標に変換し、1で生成した頂点の座標と色情報を更新。
4. RenderTargetに描画。この時、clearはしないことでピクセルの色を上塗りしていく
5. 同時に計算するピクセルの数、次に更新が必要なピクセルを選択
6. ... ピクセルの選択と描画を繰り返す

以下、同時に8ピクセルを更新していく場合を図にしてみました。zは必ず0にしているので、xyだけをWebGL経由で渡す形でもよかったと思います。

f:id:takumifukasawa:20211221104631p:plain

こうして、ピクセルの色が蓄積されていきます。

f:id:takumifukasawa:20211221002957g:plain

図の中にあるように、頂点情報の管理はVertexArrayObject(VAO)を使いました。WebGL2からの標準機能で、頂点バッファやインデックスバッファをまとめて管理してくれる便利な機能です。今回はWebGL2ベースなのでVAOを使ってみました。

developer.mozilla.org

計算したピクセルを表す頂点を描画するためのシェーダーは、渡された頂点位置と色をそのまま出力するだけなのでとてもシンプルです。

頂点シェーダー

#version 300 es
layout(location = 0) in vec3 aPosition;
layout(location = 1) in vec3 aColor;
out vec3 vColor;
void main() {
  vColor = aColor;
  gl_Position = vec4(aPosition, 1);
  gl_PointSize = 1.0;
}

ピクセルシェーダー

#version 300 es
precision mediump float;
in vec3 vColor;
out vec4 outColor;
void main() {
  outColor = vec4(vColor, 1);
}

画面に表示する

これで計算結果をRenderTargetに格納していくことができました。しかし、オフスクリーンレンダリングをしているのでまだ画面には何も映っていません。ただ、あとはRenderTargetを画面に表示させるだけでよいのでこちらもシンプルです。

canvasサイズ全面に広がる板ポリを用意し、uniformでRenderTargetのテクスチャを渡し、そのテクスチャを表示するシェーダーを書くという流れになります。

ピクセルの色計算を WebWorker に逃がす

レイトレーシングで大きな問題になってくる「計算量」ですが、ブラウザでの実行、それもなるべくリアルタイムなインタラクションつきでとなると多少絵が汚くなってもよいので、操作に影響が出にくいような構成が望ましいです。

WebWorkerの使用

ブラウザでのjavascript実行は基本的に単一スレッド(メインスレッド)なので、重い計算をCPUで行えば行うほどメインスレッドが詰まりブロッキングが発生し、UI 要素の反応が非常に悪くなっていきます。

そのため、レイトレの色計算部分をWebWorker に投げる ことにします。具体的には以下のような流れになります。

1. レイトレに必要な情報(更新するピクセルの数など)をWebWorker.postMessageで投げる
2. WebWorkerで計算
3. 更新する頂点座標・色情報群をWebWorker.onmessageで受け取る
4. RenderTargetに描画
5. 1に戻る

developer.mozilla.org

webworker関連の流れを一部抜粋してみます。実装上ではworkerを複数生成しています。

  ...

  // workerに投げる
  const tryExecWorker = async (i) => {
    ...
    /*
     * args: workerに送るデータ
     * {
     *   scene, // シーン情報
     *   camera, // カメラ情報
     *   width, // 横幅
     *   height,  // 縦幅
     *   sampleCountPerPixel, // ピクセルごとにスーパーサンプリングする数
     *   maxRayIterationCount, // 最大追跡数
     *   updatePixelIndexes // 更新するピクセルのインデックス群の配列
     * };
     */
    this.#workers[i].worker.postMessage([args]);
    ...
  }

  // workerから受け取る
  worker.onmessage = async (e) => {
    const [args] = e.data;          
    // RenderTargetの更新
    this.updatePixels(args);
    tryExecWorker(i)
  };

  updatePixels({ positions, colors }) {
    // 頂点ごとのデータが配列の入れ子になっているのでflatをかける
    // updateAttribute内ではbufferSubDataでデータを更新している
    this.#pointGeometry.updateAttribute('position', positions.flat());
    this.#pointGeometry.updateAttribute('color', colors.flat());
    this.updateRenderTarget();
    ...
  }
...

// workerのスクリプト
self.onmessage = (e) => {
  const [args] = e.data;
  // calcPixelsColorは色計算をするメソッド
  const result = RaytraceRenderer.calcPixelsColor(args);
  postMessage([result]);
}

WebWorkerに渡すデータ構造の注意

レイトレとは少し話がずれます。

メインスレッドで重い計算をすることで発生しうる問題の回避につながるWebWorkerですが、使用に際し注意点・ハマりどころがあります。

それは、渡すことのできるデータと渡せないデータがあることです。例えばWebWorkerに関数を渡すことができまん。

以下はMDNからWebWorkerに渡されるデータについての注釈の抜粋です。

メインページとワーカーの間で渡されるデータは、共有ではなくコピーされます。オブジェクトは、ワーカーに渡されるときにシリアライズされ、その後、反対側でシリアライズが解除されます。ページとワーカーは同じインスタンスを共有しないため、最終的には両側に複製が作成されます。ほとんどのブラウザーはこの機能を構造化複製として実装しています。

Functionオブジェクトがこの例で、インスタンスしたクラスをpostMessageで渡してもメソッドは渡されません(インスタンス自体がObjectに変換される)。

developer.mozilla.org

developer.mozilla.org

chromeで下記を実行すると Object で { hoge: "hogehoge" } が出力されます。クラスのインスタンスではなくなっていることが分かります。プライベートフィールドも関数同様コピー対象にはなっていないようですね。

class Hoge {
  hoge;
  #fuga;
  constructor(hoge, fuga) {
    this.hoge = hoge;
    this.#fuga = fuga;
  }
  echo() {
    console.log(this.hoge);
    console.log(this.#fuga);
  }
}

const hoge = new Hoge("hogehoge", "fugafuga");

const worker = new Worker(URL.createObjectURL(new Blob([`
self.onmessage = (e) => {
  console.log(e.data[0]); // Object { hoge: "hogehoge" }
}
`])));

worker.postMessage([hoge]);

このように、渡すデータ構造には注意が必要なのでそれを踏まえた設計をしてあげる必要があります。今回自分はクラスのプライベートフィールドを多用した結果この問題に引っかかり、関連部分は全部パブリックにしました。

今後

BVHやkd-treeによる探索の最適化は今回入れていないので次にやってみたいことです。BVHは途中まで実装してみていたのですがメッシュ分割を踏まえたBVHのやり方がいまいち分からず、今回は取りやめました。

また、パストレーシングをブラウザでやってみたら負荷がどうなるかが気になっています。計算負荷的にインタラクション連動はあまり望めないかもしれませんが、時間がかかってもブラウザでパストレの絵が出せたらとても楽しそうですね。

参考

こちらにまとめさせていただきました。

github.com

最後に

レイトレ楽しかったです!ここまで読んでいただきありがとうございました!

明日は koluku さんの記事です!