レイトレースをしてみよう

プログラマーのイシドです。

今回は前々から気になっていたレイトレースについて、少し調査してみましたので記事にしたいと思います。

なぜレイトレース?

私達ゲームプログラマはリアルタイム(秒間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のデノイザーが大いに活躍していたイメージでした。

もし機会があれば、次回はデノイザーを試してみたいと思います。

最後に、今回試したソースをこちらに貼り付けておきます。

Follow me!