プログラマーのイシドです。
今回は前々から気になっていたレイトレースについて、少し調査してみましたので記事にしたいと思います。
なぜレイトレース?
私達ゲームプログラマはリアルタイム(秒間60コマや30コマなど)の限られた時間の中でゲーム画面を描画しなければいけません。描画の仕方はGPUによってポリゴンをラスタライズする手法が一般的でした。
そもそもレイトレースにふれる機会は殆ど無く、リアルタイムといえばラスタライズ。オフラインといえばレイトレース。そんなイメージが定着しておりました。そんな中現れたのがNvidiaによるリアルタイムレイトレースデモ。全くアンテナを張っていなかったので、このデモには本当に驚かされました。
もうラスタライズの時代は終わりなのか!?と色々考えたりします。今までもそうであったように、リアルタイムレイトレースもまたエンジニアが色々試行錯誤し、ゲームに合った使い方を模索していくんだろうなと思っております。そんなところで、私もレイトレースというものを触ってみたい!と思いました。
しかしレイトレース新人な私は右も左もわかりません。グラボも無いし、DirectX12もなんか大変そうだなぁとぼんやりググっていたら、今の私に合いそうなインテルのオープンソースプロジェクトの「Embree」を発見しました。
Embree3
このライブラリはCPUで動くレイトレースライブラリです。レイを飛ばすだけのライブラリなので、ジオメトリをEmbreeに登録した後は、レイを飛ばす関数を呼ぶだけの非常にシンプルなインターフェースです。レイトレースとはなんぞや?と調べるときにすごい簡単そうに見えましたし、嬉しいことにビルド済みのバイナリもあるので、実際に簡単に使うことができました。
では早速Embreeを使ってみましょう。下記の環境で試しました。
- Windows 10 Pro 1803
- Intel Core i7-7700
- RAM 16GB
- Visual Studio 2015
Embree3を使ってみる
ダウンロード
まずはビルド済みEmbree3を落とします。こちらのサイト https://www.embree.org/downloads.html の「embree-3.6.1.x64.vc14.windows.zip」を落としました。適当な場所でZIP解凍しておきます。
お手軽に結果を確認したいので合わせてOpenGLのビルド済みも落としましょう。http://glew.sourceforge.net/ の「Binaries Windows 32-bit and 64-bit 」を落として同様にZIP解凍しておきます。
プロジェクトを作る
VisualStudioで「Win32プロジェクト」を作ります。プラットフォームを「x64」にするのを忘れずにやりましょう。
プロジェクトを右クリックし「VC++ディレクトリ」の「インクルードディレクトリ」、「ライブラリディレクトリ」にそれぞれ下記を追加。
- 「展開したフォルダ\embree-3.6.1.x64.vc14.windows\include」
- 「展開したフォルダ\\glew-2.1.0\include」
- 「展開したフォルダ\embree-3.6.1.x64.vc14.windows\lib」
- 「展開したフォルダ\\glew-2.1.0\lib\Release\x64」
また、各種dllファイルをプロジェクトのルートにコピーしておきましょう。
テストコード
テストしたコードの一部を載せます。
まず、デバイスを作り、シーンを作ります。
そして頂点やインデックスバッファを作り、ジオメトリにセット、そしてジオメトリをシーンにセット。特に複雑なことはありません。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
// 各種生成 device = rtcNewDevice(nullptr); scene = rtcNewScene(device); positionBuffer = rtcNewBuffer(device, sizeof(positions)); indexBuffer = rtcNewBuffer(device, sizeof(indeces)); auto geometry = rtcNewGeometry(device, RTC_GEOMETRY_TYPE_TRIANGLE); // バッファコピー memcpy(rtcGetBufferData(positionBuffer), positions, sizeof(positions)); rtcSetGeometryBuffer(geometry, RTC_BUFFER_TYPE_VERTEX, 0, RTC_FORMAT_FLOAT3, positionBuffer, 0, sizeof(float3), ARRAYSIZE(positions)); rtcUpdateGeometryBuffer(geometry, RTC_BUFFER_TYPE_VERTEX, 0); memcpy(rtcGetBufferData(indexBuffer), indeces, sizeof(indeces)); rtcSetGeometryBuffer(geometry, RTC_BUFFER_TYPE_INDEX, 0, RTC_FORMAT_UINT3, indexBuffer, 0, sizeof(uint3), ARRAYSIZE(indeces)); rtcUpdateGeometryBuffer(geometry, RTC_BUFFER_TYPE_INDEX, 0); // コミット geomID = rtcAttachGeometry(scene, geometry); rtcCommitGeometry(geometry); rtcCommitScene(scene); |
レンダリングでは、対象ピクセルをワールド座標に変換し、視点からのレイを構築したら、Embree3にレイトレースしてもらいます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 |
auto idx = x + y * WINDOW_WIDTH; auto screen = float3{ static_cast<float>(x), static_cast<float>(y), 0 }; screen.x += -(WINDOW_WIDTH / 2.0f) + 0.5f; screen.y += -(WINDOW_HEIGHT / 2.0f) + 0.5f; screen.z += -cosFov; auto world = mul(proj, screen); auto rayDir = normalize(world); // レイを飛ばす RTCRayHit rayhit; { auto& ray = rayhit.ray; auto& hit = rayhit.hit; ray.org_x = eye.x; ray.org_y = eye.y; ray.org_z = eye.z; ray.dir_x = rayDir.x; ray.dir_y = rayDir.y; ray.dir_z = rayDir.z; ray.tnear = FLT_EPSILON; // 範囲の始点 ray.tfar = INFINITY; // 範囲の終点.交差判定後には交差点までの距離が格納される. ray.time = 0; ray.mask = 0; ray.id = 0; ray.flags = 0; hit.geomID = RTC_INVALID_GEOMETRY_ID; hit.primID = RTC_INVALID_GEOMETRY_ID; } { RTCIntersectContext context; rtcInitIntersectContext(&context); rtcIntersect1(scene, &context, &rayhit); } |
後は結果を使ってピクセルのカラーを決定します。
その際に遮蔽チェックをし、見えている場合のみライトを当てます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 |
if (rayhit.hit.geomID != RTC_INVALID_GEOMETRY_ID) { // 衝突座標 auto pos = eye + rayDir * (rayhit.ray.tfar); auto normal = normalize(float3{ rayhit.hit.Ng_x , rayhit.hit.Ng_y, rayhit.hit.Ng_z }); // ポイントライト auto toLight = lightPoint - pos; auto lightDir = normalize(toLight); auto len2 = dot(toLight, toLight); auto len = sqrt(len2); auto power = lightCol / max(len2, 1); // 遮蔽チェック auto visible = 1.0f; { RTCRay ray; ray.org_x = pos.x; ray.org_y = pos.y; ray.org_z = pos.z; ray.dir_x = lightDir.x; ray.dir_y = lightDir.y; ray.dir_z = lightDir.z; ray.tnear = 0.0001f; // 範囲の始点 ray.tfar = len; // 範囲の終点.衝突した場合マイナスが入る。 ray.time = 0; ray.mask = 0; ray.id = 0; ray.flags = 0; RTCIntersectContext context; rtcInitIntersectContext(&context); rtcOccluded1(scene, &context, &ray); if (ray.tfar < 0) { visible = 0.0f; } } // ランバート auto p = float3{ 1,1,1 }; // 反射率はすべて白。 auto col = p / (float)M_PI * power * visible * max(dot(lightDir, normal), 0); // 格納 pixels[idx].r = static_cast<uint8_t>(min(col.x, 1) * 255.0f); pixels[idx].g = static_cast<uint8_t>(min(col.y, 1) * 255.0f); pixels[idx].b = static_cast<uint8_t>(min(col.z, 1) * 255.0f); } else { pixels[idx].r = 0; pixels[idx].g = 0; pixels[idx].b = 0; } |
今回テストでレンダリングした画像はこんな感じです。
素材はすべて白ですし、トーンマップをしていないのでだいぶ白飛びしてしまっています。
また二次反射もさせていないので、レイトレースっぽくないかもしれません。
レイトレースできた
この画像は600×400の解像度でおおよそ35msかかっています。ですので、プライマリーレイの数はおおよそ秒間7MRay本です。(オクルージョンレイも飛ばしているので1ピクセル2本のレイを飛ばしています)
テストする前は『どうせ激重・・・』なんて思っていましたが、CPUでのレンダリングも思ったより高速で、このぐらいの解像度なら30FPS程度出すことができました。ちょっとびっくりです。
なお、TBBを使った分散をすればもっとパフォーマンスを稼ぐことができます。embree3のサンプルを覗くと100MRayを超えているサンプルも有りました。またインテルコンパイラ版のサンプルもあり、そっちは更に高速になっていました。
RTX2080は10GRay/sらしいので、比べると1/100程度のパフォーマンスしか出ませんが、メモリだったりのGPU的制約が無いのはやりやすいかなと思いました。
今後
実際のレイトレースではモンテカルロ積分を使ってレイを飛ばしまくったり、ロシアンルーレットを併用して二次レイを飛ばしたりすることで、よりリアリティのある絵をレンダリングすることができます。
また、レイトレースはサンプル数が少ないとブツブツした特有のノイズがのります。モンテカルロ積分は2倍の品質にするのに4倍のサンプルが必要らしいので、ある程度行くと、いくらレイを飛ばしても全然絵が変わらない!なんてことになりがちです。
そこで登場するのがデノイザーです。探すとインテルからも出ていました。
https://openimagedenoise.github.io/
急にリアルタイムレイトレースが現実的になった裏には、こういった別の技術革新があるのかもしれません。実際に今年のCEDECで行われていたリアルタイムデモの講義では、Nvidiaのデノイザーが大いに活躍していたイメージでした。
もし機会があれば、次回はデノイザーを試してみたいと思います。
最後に、今回試したソースをこちらに貼り付けておきます。