Metal RayTracing の覚え書き

二次レイの条件

二次レイの射出の条件は、一次レイでオブジェクトにヒットしたレイで、かつジオメトリーだった場合に、IntercectionPoint を origin に、法線を direction にしてトレースする。

何も交差しなかった、かつ光源にヒットしたレイはバウンスする余地がないためレイは終了する。ジオメトリーにヒットしたレイはバウンスするようになっている。

ShadowRay, ShadowKernel では、光源との間に何もヒットしなかったら ShadeRay の値をレイを飛ばしたピクセルに結果を書き込む。

二次レイの射出

Metal レイトレサンプルの n 次レイの指定は CPU 側のループで調整。二次レイは一次レイでヒットした位置から射出される。一次レイでは ray.color = 1.0 だったが、二次レイでは、一次でヒットした surface color のみを ray.color に乗算して、二次レイに射出している。(ライティング結果は乗せない)

Metal Raytracing Lighting

Metal Raytracing Sample のライティング計算のコア部分は ShadeKernel 内で行われる。ShadeKernel は ShadowKernel とセットで計算され、複数回のライトバウンスを考慮するときは、このセットを2回、3回と呼び出す。

Inverse-square law

逆二乗の法則。光の強さは距離の二乗に反比例する。つまり、距離が2倍になれば強さは 1/4。距離が4倍離れれば、強さは 1/16になる。

// ライト距離の逆数を算出
float3 lightDirection = lightSamplePosition – intersectionPoint;
lightDistance = length(lightDirection);
float inverseLightDistance = 1.0f / max(lightDistance, 1e-3f); // 0 除算回避

// 正規化。lightDirection に逆数を乗算すれば方向だけが取れる?
lightDirection *= inverseLightDistance;

// 逆二乗の法則で、lightColor の値を減衰させる
lightColor = light.color;
lightColor *= (inverseLightDistance * inverseLightDistance);

// 更にライトは、ライト自体の放射強度も変わる
lightColor *= saturate(dot(-lightDirection, light.forward));

lightDirection がマイナスなので、ライト光源から IntersetcionPoint へのベクトル。それと light.forwad との内積を取っているので、ライト光源自体の角度による減衰のために、この計算がある?エリアライトで light.forward 方向が一番強く放射するようになっている。でも Point Light の時も必要なのか?、

// 更にライトは、ライトの方向とサーフェスの法線によっても減衰する
lightColor *= saturate(dot(surfaceNormal, lightDirection));

サーフェスと lightDirection の内積で、面にあたる光の強度を変える。

// 最終的に交差点での色と、ライトの色が最終的な色になる
shadowRay.color = lightColor * intersectionPointColor;

2次レイ、3次レイ

1次レイの場合と処理は ray.color = 1.0f で初期化。サーフェースの色が複数回跳ね返るため、2次レイを投げる時、1次レイの color (lightは含まない?) と、二次レイでヒットした箇所の色を color * surfaceColor の結果を ray.Color に入れて投げている。

Metal Raytracing

Metal for Accelerating Ray Tracing を利用することで Apple 環境でもレイトレーシングを試すことが出来ます。Metal (Apple) の場合 RTX のような専用ハードウェアを持っている訳ではないので、コードも既存のパイプラインとほぼ同じでコンピュートシェーダで処理されます。

GameViewContoller.m の ViewDidLoad の中で Renderer.mm を初期化。InitWithMetalKitView 関数を呼び出し、Metal 初期化とレンダリングに必要な下記オブジェクトを生成。

  • Metal の MTLLibrary と MTLCommandQueue の初期化
  • Pipeline を作成 (Pipeline State Object と同じ)
    • メインとなるシェーダーは4つ
    • rayKernel
    • shadekernel
    • shadowKernel
    • accumulateKernel
  • Buffer の作成
  • Intersector の作成
    • MPSRayIntersector
      • Ray と Geometry の交差判定をテストする
    • MPSTriangleAccelerationStructure
      • Metal の Acceleration Structure

リソース生成

Renderer.mm の drawableSizeWillChange を呼び出し、レンダーターゲットやレイトレーシング結果を保持する MTLTexture と MTLBuffer を生成する。

Metal では rayBuffer, shadowRayBuffer, intersectionBuffer の 3つの Buffer を作っている。バッファサイズは rayStride * rayCount (width*height)。rayStride はユーザーが定義した、Ray 構造体 (origin, direction, mask, maxDistance, color) の大きさ。Ray と Intersection の結果は GPU 内で生成して使われる。

RenderTargets[2] と AccumulationTargets[2] と RandomTexture[1] を RGBA32Float で作っている。RandomTexture は、各 Pixel を rand() % 1024 * 1024 の値で初期化。

Metal は MTLTexture は Compute Encoder の setTexture に。MTLBuffer は Compute Encoder の setBuffer にバインドする。

Intersector

Ray が missed (何も交差しなかった) の場合は、IntersectionBuffer が返す値はマイナスになる。ray.maxDistance が >= 0 以上で IntersectionBuffer がマイナスの場合は、レイを放射したが、何にもヒットしなかった(Miss)と判定できる?

RayKernel

ただのコンピューターシェーダー。実際のレイトレーシング前の初期化処理など。放射する最初のレイの原点位置と方向とマスクの定義。描画結果を格納する RenderTargets[0] を (0,0,0) で初期化。

ShadeKernel

ShadeKernel と ShadowKernel は2次レイ、3次レイと複数回呼び出される。1次レイはスクリーンスペースから witdh * height 数を放射する。その時に、下記の3つの状態に別れる。なおレイトレースの前提としてライトソースも Acceleration Structure に含む。

  • Geometry に当たった場合はライティング計算
  • ライトに直接レイが当たれば、ライトの色をピクセル値として書き込む
  • 何にも当たらなければそのレイは終了する

ポリゴンと Intersection したレイの処理。シーンにあるオブジェクトはあらかじめ、Geometry か Light のどちらかの Mask を持っており、

二次レイを投げるための用意として、ShadeRay の origin と direction を更新している。

ShadowKernel

ShadowRay がライトまでの道の中で、他のオブジェクトに当たったかどうかを判定する。もし何のオブジェクトにも当たらない場合は ShadowRay の Origin Position は、影の中にはいなかったと判定できる。

ShadowRay は先に実行された ShadeRay で当たった位置から、ライト方向への Direction を持ったレイである。

ShadowRay が Light Source への道の途中で何かに当たった (intersectionDistance > 0) 時は、それは影の中にいると判定できるので、そのピクセルはそのままにしておく。逆に、Light Soruce への途中で何もなかった場合はライトが届いているということなので、該当ピクセルのシェーディングを行う。色情報は shadowRay.color の中で保持している。光は加算で強くなるので、該当ピクセルに shadowRay.color を加算して明るくする。

ShadowKernel に設定する SrcTex は ShaderKernel での DestTex (RenderTargets[0])。ShadowKernel の DestTex は RenderTargets[1]。ShadowKernel の終了時に、RenderTargets[1] の内容を [0] と入れ替えている。

AccumulateKernel

ノイズリダクションのため、複数フレームに渡る描画結果の平均を取っている。ShaderKernel, ShadowKernel の描画結果を RenderTargets[0] (SrcTex)として入力。それとは別に 1Frame 前の描画結果を AccumulationTargets[0] として残しておき、それを prevTex として使う。最終結果を AccumulationTargets[1] に書き出している。 

float3 prevColor = prevTex.read(tid).xyz;
prevColor *= uniforms.frameIndex;

prevTex は前回までのフレームの平均値が入っている。この前回までの描画結果と、現在のフレーム値を乗算することで、現在フレームまで加算し続けた結果と見なす値に一旦復元している。これを Accumulation と見なす。

color += prevColor;
color /= (uniforms.frameIndex + 1);

SrcTex の値に前回フレームまでの Accumulation を加算。最後に SrcTex を加算した分を + 1 した値で割れば、フレームの平均が求まる。

ただ、このやり方だと、frameIndex の値が大きくなりすぎると、prevColor の値が飽和してしまうので注意。そのため frameIndex の値が大きくなりすぎないように定期的に調整しないといけないが、frameIndex の値が小さいと色が収束しない。開始数フレームはノイズが多いのはそのため。雑に 200 フレームを超えたら 100 フレームに戻すみたいなコードを書いたら、以外と見栄えは気がつかない結果だった。