👏

WebGLによるPath Tracing

2024/01/07に公開

作ったもの

画面
https://myuon.github.io/webgl-path-tracer/

コード
https://github.com/myuon/webgl-path-tracer

はじめに

path tracerは人生で何度か実装したことがあるが、基本的にCPUベースのものだけだったので、GPUを使っても何かしてみたいと思ったのがきっかけ。
以前からWebGLには興味があったので、WebGLで実装をしてみることにした。

以下ではWebGLは初めて、path tacingは割と知っている人に向けてという前提で書く。

座学

WebGLの入門には、(めちゃくちゃ有名なサイトであるところの)以下のサイトを参考にさせていただき、概要を掴むことができた。

https://wgld.org/sitemap.html

これのWebGLのセクションをある程度読んで雰囲気を掴むことと、WebGL2のセクションを読んでWebGL2で何が変わったかを把握することを最初に行った。

(Path Tracingにおいては、vertex shaderで複雑なポリゴンを扱うようなことは必要がなく、基本的に1枚の板ポリゴンを画面全体に対して貼ってあとはfragment shaderで全ての計算を行うため、実際には後半のセクションに書いてあることはあまり関係がないのですっ飛ばしても良いと思う)

大まかな実装

今回の実装では、画面を開いている間に計算を蓄積し随時反映することで、sppがどんどん上がっていき画面が綺麗になっていく、というようなものを作るつもりであった。

それには、現在のframeでの計算をするためのshaderと、過去の結果を保持して平均を取るためのshaderと2つのshaderが必要となる。
これらをそれぞれコンパイルして、JS側でプログラムを切り替えながらレンダリングを行うことになる。

事前に2枚のtexturesを用意しておき、これらをswapしながら値を書き込んでいくイメージ。
メインループは以下の通り。

// 現在のframeの値をrender --------------------------------------------
gl.useProgram(program);
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);

// 過去の結果がtextures[0]に入っているので、これをbindする
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, textures[0]);
gl.uniform1i(programLocations.texture, 0);

// uniform変数への値を渡すのをこの辺りで行う
...

// framebufferを使って、textures[1]をattachし、ここに現在のframeの計算結果を書き込む
gl.bindVertexArray(shaderVao);
gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
gl.framebufferTexture2D(
  gl.FRAMEBUFFER,
  gl.COLOR_ATTACHMENT0,
  gl.TEXTURE_2D,
  textures[1],
  0
);

// render
gl.drawElements(gl.TRIANGLES, 6, gl.UNSIGNED_SHORT, 0);
gl.bindFramebuffer(gl.FRAMEBUFFER, null);

textures.reverse();

// canvasに反映 ------------------------------------------
gl.useProgram(rendererProgram);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, textures[0]);
gl.uniform1i(rendererProgramLocations.texture, 0);
gl.uniform1i(rendererProgramLocations.iterations, iterations);

gl.bindVertexArray(rendererVao);
gl.drawElements(gl.TRIANGLES, 6, gl.UNSIGNED_SHORT, 0);

// tick ----------------------------------------------
gl.flush();

iterations++;

現在のframeの計算用のfragment shaderは以下のような感じ

#version 300 es

precision highp float;

uniform sampler2D u_texture;
uniform int spp;

in vec2 v_texcoord;
out vec4 outColor;

void main(void){
    // path tracingを行なって、計算する

    vec4 prev = texture(u_texture, v_texcoord);
    vec4 current = vec4(color / float(spp), 1.0);

    outColor = prev + current;
}

u_texture に過去のデータが蓄積されている(単純に計算したcolorを全て足し算した値が入っている)ので、それとcurrentの値を足してoutに吐いている。

canvasへの反映を行うfragment shaderは以下のような感じ

#version 300 es
precision highp float;

uniform sampler2D u_texture;
uniform int iterations;

in vec2 v_texcoord;
out vec4 outColor;

void main(){
    vec3 color = texture(u_texture, v_texcoord).xyz / float(iterations);
    // gamma correction
    outColor = vec4(pow(clamp(color, 0.0, 1.0), vec3(0.4545)), 1.0);
}

gamma correctionはしているが、それ以外は u_textureiterations で割って平均を取る操作しかしていない。

以上のようにして、ピクセルごとの値を蓄積することができた。

Float textureについて

上記のようなことをやるためには、 u_texture には1を超える値が保存できるようにしなければならない。
EXT_color_buffer_float という拡張があるらしいので、それを入れておく。(これはWebGL2用で、WebGL1では入れるべき拡張機能は少し違う)

gl.getExtension("EXT_color_buffer_float");

これがONになっていたら、以下のようにfloat textureが使える。

  const textures = Array.from({ length: 2 }).map(() => {
    const tex = gl.createTexture()!;
    gl.bindTexture(gl.TEXTURE_2D, tex);
    gl.activeTexture(gl.TEXTURE0);
    gl.texImage2D(
      gl.TEXTURE_2D,
      0,
      gl.RGBA32F,
      canvas.width,
      canvas.height,
      0,
      gl.RGBA,
      gl.FLOAT,
      null
    );
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
    gl.bindTexture(gl.TEXTURE_2D, null);

    return tex;
  });

困ったこと: 乱数と精度

Path Tracingでは乱数がとにかく必要になるが、fragment shaderでは簡単には利用ができない。
とりあえず以下の関数を用いていて、実際に使う場面になるべく毎iterationの毎pixelごとに違う値になるように入力をよしなに作っている。

highp float rand(vec2 co){
    highp float a = 12.9898;
    highp float b = 78.233;
    highp float c = 43758.5453;
    highp float dt = dot(co.xy ,vec2(a,b));
    highp float sn = mod(dt,3.14);
    return fract(sin(sn) * c);
}

また、「微小な数」を0の代わりに使いたいことがあるが、これを小さくし過ぎると謎の模様ができる、といった問題があった。
私の環境では 1e-2 にしているが、これを通常のCPUの実装のノリで 1e-3 以下にすると前述の問題が発生する。おそらくGPUの計算精度がそこまで高くないことによるものだと思われるが、詳細はよくわからない。


(ダメな例)

解決方法として、あらかじめ精度の高い乱数を仕込んだtextureを用意しておきそれをseedとして採用しxorshiftを実装する、といった方法をしょうかいされているか紹介されている方もいた。

https://blog.teastat.uk/post/2020/12/implementing-gpu-path-tracer-with-open-gl-3-3/

シーンデータをCPUからGPU側に渡す部分について

シーン上のデータとして、sphereやtriangleの座標やmaterialに関する情報などをfragment shaderに渡す必要がある。

最初のうちはshader側に直接定数で埋め込んでも良いが、CPU側で制御しておいた方が何かと楽だろう。
やり方として、多分(?)以下の2つの方法がある。

  • UBOに詰めて渡す
  • textureに詰めて渡す

UBOでやる場合(注意)

私の場合は、UBOだと一度に渡せるサイズの上限が厳しめで、1万を超えるようなtriangleの情報を渡せないという不満が致命的だったので後者のtextureに詰める方法をとったことに注意。
ただし、UBOをやりたい人のためにハマった点などを記述しておく。

UBOでやる場合、shaderはサイズのわからない配列を作るのができないので、あらかじめ大きめのarrayを作っておいてuniform変数としてサイズを別で渡しておく必要があった。

また、std140でレイアウトを宣言するなどをすることになると思うが、その場合はalignmentに注意する必要がある。いわゆるプログラミング言語のコンパイラが構造体などに対して行う処理と同じで、余白などが発生する場合があるのでCPU側でarrayを作るときに注意する必要がある。
(少なくともstd140では、フィールドの順序を並び替えるようなことはないっぽい)

https://qiita.com/hoboaki/items/b188c4495f4708c19002

例えばfloatは4byte alignmentのため、 float, vec3, float の順でフィールドを持つ構造体をstd140で渡す場合、 float,-,-,-,vec3(1),vec3(2),vec3(3),float で8*4byteのデータを渡すことになる(ハイフンは使われない)。

Textureに詰める場合

textureの場合、適当なtextureSizeを持つデータを作ってそこに詰めていくことになる。

雑に1000x1000pixel程度のtextureを用意するとなると、pixelあたりRGBAのデータでざっと400万程度のfloatを詰めることができる。
1つのshapeに使うデータとして仮に40個のfloatが必要と仮定しても、1枚のtextureで10万のshapeを詰められるので十分なサイズである、と確信して私はこちらの方法を選んだ。
(UBOだとshapeが1500くらいですでにblock sizeの上限エラーになってしまったため)

textureに詰める場合は、JS側でFloat32Arrayを用意してfloat textureを初期化する。

例えば以下のような感じ。

  gl.activeTexture(gl.TEXTURE1);
  const triangleTexture = gl.createTexture()!;
  gl.bindTexture(gl.TEXTURE_2D, triangleTexture);
  gl.texImage2D(
    gl.TEXTURE_2D,
    0,
    gl.RGBA32F,
    textureSize,
    textureSize,
    0,
    gl.RGBA,
    gl.FLOAT,
    triangleTextureData
  );
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
  gl.bindTexture(gl.TEXTURE_2D, null);

triangleTextureDataでは、GPU側と取り決めした規則でデータを詰めていけば良い。
GPU側は、例えば以下のようにして詰めたデータを取り出せる。

const int textureSize = 1024;
Triangle fetchTriangle(int index) {
    int size = 24 / 4;
    int x = (index * size) % textureSize;
    int y = (index * size) / textureSize;

    vec3 vertex = texture(triangles_texture, vec2(float(x) / float(textureSize), float(y) / float(textureSize))).xyz;
    float material_id = texture(triangles_texture, vec2(float(x) / float(textureSize), float(y) / float(textureSize))).w;
    vec3 edge1 = texture(triangles_texture, vec2(float(x + 1) / float(textureSize), float(y) / float(textureSize))).xyz;
    ...

    return Triangle(...);
}

texture関数でアクセスする際には、座標を [0,1] の範囲に収まるようにしてあげる必要があることに注意。

今後やりたいこと

今は適当なobjファイル、mtlファイル、それとMitsuba renderer用のxmlファイルのパーサーを書き、より本格的なシーンのレンダリングをやろうとしている。

今後やっていきたいこととしては、

  • BVHツリーの実装
  • ノイズ等の精度をあげる
  • NEEの実装

などがあるかなと思っている。

Discussion