Crystal Renderer 技術解説②

概要 この記事について

UE5 向けのプラグインである Crystal Renderer についての技術解説です。
全3回+αに分けての解説を予定しています(これはボリューム等を見て変更する可能性があります)。

第1回:Crystal Renderer の原理
第2回:光の反射と屈折(本記事)
第3回:形状をマテリアルに取り込む
補講:色収差について

本記事は第2回です。先に前回の記事を読んで頂くことをお勧めします。
今回は物理(光)と数学(ベクトル)の話がメインです。

基本情報

光の反射と屈折を計算するときに、レイおよび平面という二つの概念を扱いますのでこれについて確認しておく必要があります。
説明不要という方はこの部分を飛ばして下さい。

レイについて

レイとは仮想的な光線であり、「開始点の座標」及び「進行方向のベクトル」を情報として持ちます
(プログラミング的にはこれを構造体としても良いですが、片方しか必要としない計算も多いため Crystal Renderer ではバラして扱っています)。
レイは「何かが直進したときにどこにぶつかるのか」を調べる際に頻繁に使われる概念ですのでご存知の方も多いでしょう。
ゲーム開発の場面においてはレイキャストという熟語で触れることが多いかもしれません。
検索すればどういうものかは分かると思いますので、ここでの説明は割愛します。

平面について

ここで言う平面とは無限に続く平面を指します。
平面は「法線ベクトル」および「平面上の座標と法線ベクトルとの内積スカラー値)」で表されますので、プログラム上は四次元ベクトルに格納することができます。
このあたりで何を言ってるか分からないという方は次の記事などを読むとよいかもしれません。
ゲーム作りとかCGとかに関わる数学(初歩)① #数学 - Qiita
UE5 においては平面を表現するために FPlane(中身は TPlane)が定義されていますが、コンストラクタ等からお察しできるようにデータの内容としてはやはり四次元ベクトルと同等です。

光の反射と屈折


屈折率の異なる物質の境界に当たった光は一部が反射して戻り、一部が屈折して進入します。
Crystal Renderer では以下の関数でこれらの経路を計算しています(多少編集しつつ抜粋)。

/**
* separate ray into refraction and reflection with weights
*/
void CollideRayWithPlane(float3 rayNormalized, float4 plane, float startSideRelativeRefraction, out float reflectionRate, out float3 reflection, out float3 refraction )
{
    float3 rayVertical = dot(plane.xyz, rayNormalized) * plane.xyz;
    reflection = rayNormalized - rayVertical*2.0;

    float3 rayHorizontal = rayNormalized - rayVertical;
    float3 refractHorizontal = rayHorizontal * startSideRelativeRefraction;
    float horizontalElementSquared = dot(refractHorizontal, refractHorizontal);             
    float borderDot;

    if( startSideRelativeRefraction > 1.0 )
    {
        borderDot = sqrt(1.0-1.0f/(startSideRelativeRefraction*startSideRelativeRefraction));
    }
    else
    {
        borderDot = 0.0;
    }

    // full reflection
    if( horizontalElementSquared >= 1.0 )
    {
        reflectionRate = 1.0;
        refraction = plane.xyz;

        return;
    }               
            
    float verticalSizeSquared = 1-horizontalElementSquared;
    float3 refractVertical = rayVertical * sqrt( verticalSizeSquared / dot(rayVertical, rayVertical) );
    float BaseReflection = pow((1.0-startSideRelativeRefraction)/(1.0+startSideRelativeRefraction), 2);

    refraction = refractHorizontal + refractVertical;
    reflectionRate = CalcReflectionRate(rayNormalized, plane.xyz, BaseReflection, borderDot);

    return;
}

反射について

光が平面上で反射するときの経路について考えます。
こういったことをプログラミングするときは、「まっすぐ跳ね返る」「鏡のように跳ね返る」といった漠然としたイメージではなく数学的な表現で考える必要があります。
この場合、反射後の進行方向は「元の進行方向ベクトルのうち、平面の法線方向成分のみが反転した向き」のように表現できます。
自信のない方は写経でも良いので一度ご自身でプログラミングしておくことをお勧めしますが、以下のように簡単なコードで計算できます(上のものからの抜粋)。
法線方向成分を抽出して二つ分引き算すれば反転するということですね。

float3 rayVertical = dot(plane.xyz, rayNormalized) * plane.xyz;
reflection = rayNormalized - rayVertical*2.0;

屈折について

屈折についてはもう少し複雑な計算になります。
屈折時の進行方向についてはスネルの法則というものに従います。
スネルの法則 - Wikipedia
ようは、屈折前後の進行方向ベクトルについて平面に対して水平な成分の比が屈折率の比と反比例するということです。
進行方向ベクトルが正規化(長さを1にすること)されていれば、
①元の進行方向の水平成分に屈折率の比を乗算して屈折後の水平成分を算出
②長さが1となるよう垂直成分を補う
という手順で屈折後の進行方向を得ることができます。
上に示したコードから間引きながら関係する部分を抜粋したものを以下に示します。

   float3 rayVertical = dot(plane.xyz, rayNormalized) * plane.xyz;
    float3 rayHorizontal = rayNormalized - rayVertical;
    float3 refractHorizontal = rayHorizontal * startSideRelativeRefraction;    // 屈折後の水平成分

    float horizontalElementSquared = dot(refractHorizontal, refractHorizontal);
    float verticalSizeSquared = 1-horizontalElementSquared;
    float3 refractVertical = rayVertical * sqrt( verticalSizeSquared / dot(rayVertical, rayVertical) );    // 屈折後の垂直成分
    refraction = refractHorizontal + refractVertical;

反射と屈折との割合

この割合は光の当たる角度(入射角)によって異なります。
基本的には浅い角度であるほど反射の割合が高くなることが知られています(俗にフレネル反射とか言われる効果です)。
また、屈折率の高い物質から低い物質への境界においては、入射角が一定以上の場合に「全反射」が起こります。
屈折率の高いダイヤモンドの中に複雑な反射が見えるのはこのことと関係があります。
反射の割合についてはフレネルの式というもので計算できます。
フレネルの式 - Wikipedia
実際の光には「波の振動方向」というものがあり、それに応じて反射率が異なるために複雑な式となります(ですので、水面やガラスで反射した光は偏光します)が、通常のコンピューターグラフィックスにおける光にそんなものはありませんので近似式を用います。
ページの下の方にあるコレ↓ですね。

この式にならい、Crystal Renderer では以下のような関数によって反射率を計算しています。

float CalcReflectionRate(float3 normal, float3 ray, float baseReflection, float borderDot)
{
    float normalizedDot = clamp( (abs(dot(normal,ray)) - borderDot) / ( 1.0 - borderDot ), 0.0, 1.0);
    return baseReflection + (1.0-baseReflection)*pow(1.0-normalizedDot, 5);
}

反射するのがこの割合で、残りは屈折すると考えます。

反射・屈折を繰り返すことについて

結晶内部で反射した光は別の位置で表面に到達し、ふたたび反射・屈折が起こります。
これが繰り返されることによって複雑なパターンが表示されることになります。
理論上反射は無限に起こりますが、計算の都合上適当な回数で中断することになります。
当然ですが、この反射回数に応じて表示内容が変化します。
このように繰り返しが多いほど緻密な表示になりますが、10→20では劇的な変化がないことが見て取れるかと思います。
再帰的な処理になりますのでコードもそれに従いたいところですが、シェーダーコンパイラーがこれに対応していません(多分)ので通常のループとして記述することになります。
Crystal Renderer では以下のようなループになっています。
これまでの説明では意味の分からない部分を幾らか含んでいると思いますが、
反射・屈折を繰り返す光の経路をたどりながら計算し必要な情報を配列へと保存しています。

 LOOP for( uint i = 0; i<MAX_REFLECTION; ++i )
    {
        if( i>= condition.MaxReflection )
        {
            break;
        }
        float hitTime=1000000.0;
        float4 hitPlane=float4(1,0,0,1);
        CheckCollideRayWithAllPlanes(tmpRayStart, tmpRayDirection, condition, hitPlane, hitTime);

        hitTimes[i] = hitTime;
        hitPlanes[i] = hitPlane;

        if (hitTime < 0.0)
        {
            badRay = 1;
        }
                                        
        float3 rayEnd = tmpRayStart + tmpRayDirection*hitTime;
                                
        float reflectionRate;
        float3 reflectionRay;
        float3 refractionRay;

        CollideRayWithPlane(tmpRayDirection, hitPlane, refractiveIndex, reflectionRate, reflectionRay, refractionRay);

        reflectionRates[i] = reflectionRate;            
        refractionColors[i] = SampleEnvironment(refractionRay, condition);
        depthColors[i] = float4(CalcColorCoefByDistance(hitTime, condition.MaterialColor),1);

        tmpRayStart = tmpRayStart + tmpRayDirection * hitTime;
        tmpRayDirection = reflectionRay;
    }

この結果を参照し、最後に環境マップからサンプリングした色の加重平均によって表示色を算出します。

まとめ

反射および屈折の計算方法について説明しました。
Crystal Renderer のシェーダーに関して全てではないものの、これが核心ではあります。
ただ、鋭い方はお気付きかもしれませんが、この計算を成立させるために重要な要素がまだ足りていません(冒頭にネタバレがありますが)。
それについてはまた次回をお待ちください。