このノートでは鏡面反射と屈折を実現するいくつかの道と、それぞれに何ができて何ができないかを扱います。
キューブマップと image-based lighting
もっとも基本的な環境反射はキューブマップです。六方向を向いた六枚の画像を、方向ベクトルでサンプリングします。
MSLfloat3 r = reflect(-viewDir, normal);
float3 env = environmentMap.sample(cubeSampler, r).rgb;
Metal での作成は次のとおり。
Swiftlet d = MTLTextureDescriptor.textureCubeDescriptor(
pixelFormat: .rgba16Float, size: 512, mipmapped: true)
d.usage = [.shaderRead, .renderTarget]
PBR では、反射方向を直接サンプリングするのは完全な鏡面についてのみ正しい扱いです。粗い表面は環境の円錐状の範囲を反射するため、環境マップをあらかじめ畳み込んでおく必要があります。これが IBL の split-sum 近似で、積分を二つに分けます。
1. プレフィルタ済み環境マップ。 キューブマップの各ミップレベルを、異なる粗さの GGX 分布で畳み込みます。ミップ 0 が原画(roughness 0)、最終レベルがほぼ完全なぼかし(roughness 1)です。実行時は粗さでミップを選びます。
MSLfloat lod = roughness * float(prefilteredMap.get_num_mip_levels() - 1);
float3 prefiltered = prefilteredMap.sample(s, r, level(lod)).rgb;
2. BRDF 積分ルックアップテーブル。 環境に依存しない 2 次元の表で、NdotV と roughness を軸に、フレネル項のスケールとバイアスを保持します。オフラインで一度計算すれば、あらゆるシーンで共有できます。
MSLfloat2 ab = brdfLUT.sample(s, float2(NdotV, roughness)).rg;
float3 specular = prefiltered * (F0 * ab.x + ab.y);
拡散側は別途事前積分した irradiance map を使います。環境を余弦加重半球で積分したもので、非常に小さくて済みます(32×32 で十分)。球面調和係数(float3 九つ)として保持すればさらに安上がりです。
この構成全体は、オフラインレンダラーが直接積分している内容のリアルタイム近似です。見た目がよく、コストが一定で、今日の PBR エンジンの標準装備になっています。
平面反射
水面や鏡のような平面に対しては、カメラを平面で鏡像化し、シーンをもう一度描画するという手が使えます。
Swiftfunc mirrorMatrix(plane: SIMD4<Float>) -> float4x4 {
let n = plane.xyz, d = plane.w
return float4x4(
SIMD4(1 - 2*n.x*n.x, -2*n.y*n.x, -2*n.z*n.x, 0),
SIMD4( -2*n.x*n.y, 1 - 2*n.y*n.y, -2*n.z*n.y, 0),
SIMD4( -2*n.x*n.z, -2*n.y*n.z, 1 - 2*n.z*n.z, 0),
SIMD4( -2*n.x*d, -2*n.y*d, -2*n.z*d, 1)
)
}
結果は近似なしで完全に正しくなりますが、代償はシーン全体を二度描画することです。したがって平面反射は、本当に重要な少数の平面——鏡一枚、湖ひとつ——にのみ向きます。
鏡像化は三角形のワインディングを反転させるので、setFrontFacingWinding も併せて反転してください。そうしないと背面カリングがすべてを消します。また反射面より下のジオメトリを切り落とすために斜め近平面が必要です。さもないと反射像の中に映り込みます。
スクリーンスペース反射
SSR は既に描画済みの深度・カラーバッファを使い、スクリーン空間でレイマーチングします。
MSLfloat3 rayStep(float3 origin, float3 dir, texture2d<float> depthTex, constant Params &p) {
float3 pos = origin;
for (uint i = 0; i < p.maxSteps; i++) {
pos += dir * p.stepSize;
float2 uv = projectToUV(pos, p.projection);
if (any(uv < 0.0) || any(uv > 1.0)) break; // 画面外に出た:ミス
float sceneDepth = linearDepth(depthTex.sample(s, uv).r, p);
float delta = pos.z - sceneDepth;
if (delta > 0.0 && delta < p.thickness) {
return binarySearchRefine(pos, dir, p); // ヒット:二分法で精緻化
}
}
return float3(-1); // ミス
}
SSR の利点は、追加のシーン描画なしにシーン内の動的オブジェクトを反射できることです。欠点は、パラメータ調整では解決できない構造的な破綻があることです。
- 画面外のものは反射できません。 足元の床は背後の壁を映しません。標準的な対処は画面端に向けて SSR をフェードアウトさせ、キューブマップへフォールバックすることです。
- 遮蔽された面は反射できません。 深度バッファは最前面しか記録しておらず、物体の裏側はそもそも存在しません。
- 厚みは推測です。
thicknessパラメータは「深度バッファ上の面がどれだけ厚いか」の仮定にすぎません。小さすぎればレイが物体を突き抜け、大きすぎれば引き伸ばされた破綻が出ます。
したがって SSR を単体で使うことはありません。常にキューブマップの上に重ねる層として用います。レイが当たった箇所は SSR の結果を、外れた箇所は環境マップを使います。
粗い表面の SSR は多数のレイを要し、すぐ高価になります。実用的にはピクセルあたりジッタを加えたレイを 1 本だけ飛ばし、時間方向(TAA)と空間方向のフィルタでノイズを落とします。
屈折
屈折方向はスネルの法則に従い、MSL が直接提供しています。
MSLfloat3 refracted = refract(-viewDir, normal, eta); // eta = n1 / n2
refract は全反射(密な媒質から疎な媒質へ、臨界角を超えて入射する場合)でゼロベクトルを返します。この場合は反射へフォールバックする必要があります。
MSLfloat3 t = refract(-v, n, eta);
float3 dir = (length_squared(t) < 1e-6) ? reflect(-v, n) : t;
代表的な屈折率は、水 1.33、ガラス 1.5、ダイヤモンド 2.42 です。
リアルタイムで一般的な近似はスクリーンスペースのグラブです。屈折方向をスクリーン空間のオフセットへ投影し、それで背景色をサンプリングします。
MSLfloat2 offset = refracted.xy * strength / in.position.w; // w で割る:遠いほどオフセットは小さい
float3 behind = sceneColor.sample(s, screenUV + offset).rgb;
物理的には誤り——背景が平面上にあると仮定している——ですが、ガラス、水面、陽炎には十分に説得力があり、コストはテクスチャサンプリング 1 回です。
色分散は、RGB の三チャンネルをわずかに異なる屈折率で屈折させることで模擬できます。
MSLfloat3 refr;
refr.r = sampleRefracted(eta - dispersion).r;
refr.g = sampleRefracted(eta).g;
refr.b = sampleRefracted(eta + dispersion).b;
サンプリング 3 回で虹色の縁が得られます。宝石やプリズムでは効果が顕著です。
ハードウェアレイトレーシング
ここまでの手法はすべて近似であり、それぞれに固有の破綻があります。Metal のハードウェアアクセラレーテッドなレイトレーシングはそれらに直接対処します。反射は表面から飛ばすレイそのものであり、スクリーン空間の制約もベイク済み環境マップも不要です。
代償は性能です。ハードウェア BVH 走査があっても、ピクセルあたり反射レイ 1 本とシェーディングは、キューブマップのフェッチよりはるかに高価です。現在の実務はハイブリッドです。主要な反射はレイトレースし、粗い表面と遠方の寄与は IBL で、そして疎なトレース結果からデノイザーで再構成します。
レイトレーシングは#16 アニメーションのあと、#17、#18、#19の三回で詳しく扱います。
次回はアニメーションです。