レイトレースをしてみよう
プログラマーのイシドです。
今回は前々から気になっていたレイトレースについて、少し調査してみましたので記事にしたいと思います。
なぜレイトレース?
私達ゲームプログラマはリアルタイム(秒間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ファイルをプロジェクトのルートにコピーしておきましょう。
テストコード
テストしたコードの一部を載せます。
まず、デバイスを作り、シーンを作ります。
そして頂点やインデックスバッファを作り、ジオメトリにセット、そしてジオメトリをシーンにセット。特に複雑なことはありません。
// 各種生成
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にレイトレースしてもらいます。
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);
}
後は結果を使ってピクセルのカラーを決定します。
その際に遮蔽チェックをし、見えている場合のみライトを当てます。
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倍のサンプルが必要らしいので、ある程度行くと、いくらレイを飛ばしても全然絵が変わらない!なんてことになりがちです。
そこで登場するのがデノイザーです。探すとインテルからも出ていました。
急にリアルタイムレイトレースが現実的になった裏には、こういった別の技術革新があるのかもしれません。実際に今年のCEDECで行われていたリアルタイムデモの講義では、Nvidiaのデノイザーが大いに活躍していたイメージでした。
もし機会があれば、次回はデノイザーを試してみたいと思います。
最後に、今回試したソースをこちらに貼り付けておきます。