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 さんの記事です!

【解説編】すき焼きの写真にすき焼きの3Dモデルを埋め込む方法

この記事は Tech KAYAC Advent Calendar 2021 の21日目の記事です。

こんにちは!意匠部のおばらです。

今回は アドベントカレンダー14日目の記事 でご紹介した
「すき焼きの写真にすき焼きの3Dモデルなど任意のファイルをバイナリとして埋め込む方法」
の解説記事です。

f:id:tsmallfield:20211220173219p:plain
【解説編】すき焼きの写真にすき焼きの3Dモデルを埋め込む方法

techblog.kayac.com

任意のファイルを ArrayBuffer として取得する

fetch() を使う場合

See the Pen sukiyaki.lib.urlToArrayBuffer.jsm by Tohl SMALLFIELD (@tsmallfield) on CodePen.

これだけ!便利な時代になりました。
XMLHttpRequestoverrideMimeType('text/plain; charset=x-user-defined') でバイナリテキストを取得し charCodeAt() で文字コードに変換して、、とがんばっていた頃が懐かしいです。

(参考: https://www.html5rocks.com/ja/tutorials/file/xhr2/

FileReader を使う場合

input[type="file"] や ドラッグ& ドロップ などで取得した File オブジェクトを ArrayBuffer に変換したい場合は FileReader を使います。

See the Pen sukiyaki.lib.fileToArrayBuffer by Tohl SMALLFIELD (@tsmallfield) on CodePen.

埋め込むフォーマットを決める

サイズ情報を先頭に追加

後で抜き出す際、データ(ArrayBuffer)のサイズ(何バイトか)を知る必要があるため、 埋め込むデータの先頭32ビット(4バイト)にサイズ情報を追加することにします。

See the Pen sukiyaki.lib.embedByteLength by Tohl SMALLFIELD (@tsmallfield) on CodePen.

(※ new Uint32Array(arrayBuffer)[0] = data.byteLength; としたいところですが arrayBuffer の長さが8バイト(32ビット)の整数倍とは限らないためNG)

埋め込むロジック

様々なロジックが考えられますが
今回の解説では簡単のため
・Rチャンネルの最下位1ビットのみ
つまり
・1ピクセルに1ビットずつ埋め込む
ことにします。

f:id:tsmallfield:20211220201855p:plain
1ピクセルに1ビットずつ埋め込む

※(Canvas2Dで埋め込み&抜き出しをする際)Aチャンネルを1以外にすると、経験上データが劣化します。 これは premultiplied alpha の関係かな?と思うのですが検証はできていません。以下の通り検証しました!

乗算済みアルファによるデータの劣化(2021/12/22 追記)

putImageData() でピクセルに埋め込んだデータをgetImageData()で取り出す際
Aチャンネル(アルファ)が 255 より小さいと
乗算済みアルファ(premultiplied alpha)の影響で他の R、G、B チャンネルのデータが劣化する可能性があります。
以下はデータの劣化を再現するサンプルコードです。

See the Pen LossyOperation via putImageData() and getImageData() by Tohl SMALLFIELD (@tsmallfield) on CodePen.

putImageData()でR, G, Bチャンネルのいずれかに埋め込んだ値(0〜255)を source
getImageData()で取り出した値(0〜255)をresult
・ アルファ(0〜255)をalpha
とするとresult
Math.round(Math.round(source * alpha / 255) * 255 / alpha)
ほぼ一致します。

(※ 参考 CanvasRenderingContext2D.putImageData() developer.mozilla.org

埋め込む画像のサイズを決める

f:id:tsmallfield:20211212143612j:plain
すきやきの写真

埋め込むデータのサイズ(バイト数)に応じて生成する画像のピクセル数も変える必要があります。
埋め込むデータのサイズ(byteLength)と元画像のアスペクト比(aspectRatio)から
生成する画像の幅(width)と高さ(height)を求めるコードは以下です。

See the Pen sukiyaki.lib.calcImageSize by Tohl SMALLFIELD (@tsmallfield) on CodePen.

まず単位をバイトからビットに変換します。
(※1バイトは8ビット)

const bitLength = byteLength * 8;

次に必要なピクセル数を計算します。
1ピクセルに1ビット埋め込むので以下となります。

const pixelLength = bitLength;

このとき pixelLength、width、height が次の条件を満たせばよいです。

pixelLength <= width * height

ここで、高さ(height)は 「幅 ÷ アスペクト比」(width / aspectRatio)と書き換えられます。

pixelLength <= width * width / aspectRatio

両辺に aspectRatio を掛けて変形すると

pixelLength * aspectRatio <= width ** 2
Math.sqrt(pixelLength * aspectRatio) <= width

よって

const width  = Math.ceil(Math.sqrt(pixelLength * aspectRatio));
const height = Math.ceil(pixelLength / width);

となります。
<canvas> のサイズ(ピクセル数)には上限があります。

画像のピクセルデータを取得する

画像からピクセルデータをArrayBufferとして抜き出すコードです。

See the Pen sukiyaki.lib.imageToPixelArrayBuffer.jsm by Tohl SMALLFIELD (@tsmallfield) on CodePen.

getImageData(...).data は昔は CanvasPixelArray というオブジェクトだった記憶がありますが、いつの間にか型付配列 Uint8ClampedArray になっていますね!

バイナリデータの埋め込み

地道に1ピクセルに1ビットを埋め込んでいきます。
(※この処理は Worker でやっても良いかもしれません)

See the Pen sukiyaki.lib.embedDataInPixels by Tohl SMALLFIELD (@tsmallfield) on CodePen.

埋め込んだ ArrayBuffer は Canvas2D を使用して画像のBlobに変換します。

See the Pen sukiyaki.lib.arrayBufferToPNGBlob by Tohl SMALLFIELD (@tsmallfield) on CodePen.

(※ 説明の関係で <canvas> を新たに生成していますが getImageData() のために生成した <canvas> を使い回しても良いでしょう)

以上が埋め込むロジックの解説です。

サンプルコード

ドラッグ&ドロップされたファイルをすき焼きの写真に埋め込むツールです。
軽めのファイルをドラッグ&ドロップしてみてください。

See the Pen sukiyaki.pack by Tohl SMALLFIELD (@tsmallfield) on CodePen.

データが埋め込まれたすき焼き画像からバイナリデータを取り出す

埋め込むときと同様 Canvas2D を使いピクセルデータを ArrayBuffer に変換し
Rチャンネルの最下位1ビットをつなぎ合わせていきます。

See the Pen sukiyaki.lib.extractDataFromPixels by Tohl SMALLFIELD (@tsmallfield) on CodePen.

こうして取得したArrayBufferの先頭の32ビット(4バイト)には
埋め込まれたデータのサイズ情報(embeddedByteLength)が入っています。
その情報をもとにデータ部分(embeddedArrayBuffer)を抜き出します。

const embeddedByteLength = new DataView(arrayBuffer).getUint32(0, true);
const embeddedArrayBuffer = arrayBuffer.slice(4, 4 + embeddedByteLength);

抜き出したデータ(ArrayBuffer)は必要に応じてBlobURL.createObjectURL()FileReaderなどで任意のフォーマットに変換するとよいでしょう。

例えば抜き出したデータがSVGであるとわかっている場合は次のようにします。

const blob = new Blob([embeddedArrayBuffer], { type: 'image/svg+xml' });
const url  = URL.createObjectURL(blob);
...

以上が抜き出すロジックの解説です。

サンプルコード

画像から埋め込まれたデータを抜き出すツールです。
データが埋め込まれたすき焼きの写真をドラッグ&ドロップすると
ArrayBufferを抜き出しbinファイルとしてダウンロードできます。

See the Pen sukiyaki.unpack by Tohl SMALLFIELD (@tsmallfield) on CodePen.

まとめ

実際の業務ですき焼きの写真にデータを埋め込む機会はあまりないとは思いますが
バイナリ操作を理解する良いきっかけになるのでぜひ挑戦してみてください!

ビット演算、型付配列、Canvas2D などを使った JavaScript(Node.js含む)でのバイナリ操作は
(特に生の)WebGLを扱う場合でも必要となる機会が多いです。

例えば

(3Dソフトで作成したモデルデータの)VBOのデータ構造を最適化したい
(インターリーブ化、頂点カラーを32ビット浮動小数点ではなく8ビットの符号なし整数にするなど)
(3Dソフトで作成したモデルデータの)VBOに(attribute用の)情報を追加したい
(IDやオブジェクトの中心座標など)
VBO、IBOを0から動的に生成したい
KTXなどのコンテナからGPU向けに圧縮されたテクスチャの各種情報: 画像サイズやフォーマット、gl.compressedTexImage2D() に渡すデータ部分などを抜き出したい
テクスチャに複数の情報を埋め込みたい

などです。

さいごに

前回の記事でご紹介した3Dすき焼きのサンプルコード、
「すき焼きといえば生でしょ!」ということで無駄に生WebGLでくるくる回しているのですが
実は難読化されたJavaScriptのコードの中に秘密のメッセージが埋め込まれているんです。
気づいていただけたでしょうか?

まだの方はぜひ探してみてください!

See the Pen Sukiyaki by Tohl SMALLFIELD (@tsmallfield) on CodePen.

カヤックではデザインもしたい!コードも書きたい!3Dモデリングもしたい!
そんな欲張りなアートディレクター/デザイナーを大募集中です!

www.kayac.com