以前からUnityのShaderGraphをもっと使いこなせるようになりたいという事と、もう少し物理ベースシェーダーに詳しくなりたいという思いがあり、ならば既に存在するPBRシェーダーをShaderGraphで再現してみれば勉強になるのでは?と考えました。英語のドキュメントを翻訳すると大体内容が理解できるという事に近い発想です。
今回は最も仕組みがシンプルだと思われる、LightweightRenderPipeline/Litを再現してみました。
まずは結果から
長い記事になりますので、最初に結果からお見せします。
左がオリジナルのLWRP/Lit、右がShaderGraphで再現したものです。MetallicとSmoothnessの変化にも対応できました。いろいろと制約はあるものの、そこそこ忠実に作れたのではないかと思います。
オリジナルとの大きな違いとして、現在ShaderGraphではライトを参照する事ができないので仮の値をShader内に持たせている、という事があります。これは今後のバージョンアップに期待したいと思います(間接光は現在でも扱う事ができます)同様にリアルタイムシャドウを受ける事も現時点では出来ません。
シェーダー全体の構成
今回作成したShaderGraphの全体は以下のような形になりました。
オリジナルのコードは複数のファイルに分かれており、関数によってさらに複雑に関連しているのですが、全体の構成をごく大枠で書くと以下のようになっていました。
1.マテリアルで設定するプロパティの定義
2.BRDFに必要な情報を収集
3.環境光を計算
4.メインライトを計算し、3に加算
5.アディショナルライトを計算し、3に加算
6.エミッションを計算し、3に加算
つまり先に全部のライティング結果を合算した後に陰影計算をするのではなく、ライティングの種類毎に計算して順次加算する方法のようです。この事からこのシェーダーからは単純にスペキュラーだけ、ディフューズの結果だけを取り出して扱う、という事が難しいという事が判りました。
ShaderGraphでの再現も原則はこの順序に沿って行いますが、今回は簡略化のため5のアディショナルライトと6のエミッションは省略し、メインライト1灯に限定したものを作成しています。
1.マテリアルで設定するプロパティ
ではさっそくシェーダーを作っていきます。
今回はゼロからシェーダーを作る事が目的なので、UnlitGraphをベースに、最初の工程となるプロパティの定義部分から作っていきます。
オリジナルのシェーダーではインスペクターでの表示/非表示合わせて多数のプロパティが定義されていますが、今回最終的に使ったのは、Albedo、Metallic、Smoothness、Occlusionの4種のみです。構造をシンプルにするため、Occlusion以外はテクスチャを参照しないパラメータとしました。
また本来はシーン中のライトを参照するLightColor、LightVector、LightAttenuationの値も今回はパラメータとしてインスペクターに表示させます。ShaderGraph上ではこんな感じです。
Occlusionのみテクスチャを参照するのでSampleTexture2Dノードに繋ぎます。
プロパティは以上ですが、次の”BRDFに必要な情報を収集”の前準備としてMetallicからoneMinusReflectivityとReflectivityという値を作っているようなのでこれを再現します。コードでは関数を挟むので読み辛いのですがまとめると、
oneMinusReflectivity = 0.96 - metallic * 0.96
reflectivity = 1.0 - oneMinusReflectivity
というだけでした。ShaderGraphではこうなります。
2.BRDFに必要な情報を収集
次にプロパティ以外で計算に使う要素を収集します。集めた値はオリジナルのコードではBRDFDataという構造体になっていますが、ShaderGraphではそれぞれノードのままです。SubGraph化しようとも思ったのですが、現状SubGraphはOutput側のポートに命名する事ができず型も全てVector4になってしまうので混乱しそうで止めました。
集める値は、diffuse、specular、perceptualRoughness、roughness、roughness2、grazingTermの6つです。roughness関連が多いですが、この後それぞれ出番があるようなので省略せずに全て作ります。
各値を求める式は取りまとめると以下のようなものでした。
diffuse = albedo * oneMinusReflectivity
アルベドに1-反射率を乗算。
specular = lerp((0.04,0.04,0.04), albedo, metallic)
0.04は入射角における標準誘電率係数(4%)の事だそうです。
lerpは線形補間なのでこれとアルベドの間にある、メタリックの値での位置という事になりますね…。
perceptualRoughness = 1.0 - smoothness
perceptualは知覚的な、という意味。1-スムースネス=ラフネスはイメージしやすい。
roughness = (1.0 - smoothness)²
知覚的ではないラフネスは、知覚的なラフネスを2乗したものだという事がわかります。
roughness2 = roughness²
シンプルにラフネスの2乗です。
grazingTerm = saturate(smoothness + reflectivity)
grazingは平面をかすめる、Termは~項という意味。
スムースネスと反射率の和を0-1でクランプしています。
これらをまとめてShaderGraphで表すとこんな感じです。
急に複雑に見えますが、個々の要素はそんなに入り組んではいません。
ここまででシェーディングに必要な要素が集まりました。
3.環境光を計算
実際のシェーディングはまず環境光(Global Illumination)から行われます。
LWRPの環境光は別に計算されたLightProbeとRefrectionProbe、LightMapを使って作られるので、ここではメインライトのような光源計算は行われません。
ディフューズ成分とスペキュラー成分をそれぞれ計算し、最後に加算します。
環境光のディフューズ成分
環境光のディフューズ成分の計算はとてもシンプルです。
LightMap等ベイクしたGI要素(BakeGI)を取得し、それにOcclusionとDiffuseを乗算するだけです。DiffuseはAlbedoを反射率分減じたものでした。ShaderGraphではこうなります。
環境光のスペキュラー成分
環境光のスペキュラー成分の計算は、ディフューズ成分よりも複雑です。大きな流れとしてはReflectionProbeをベースに、Smoothnessによる粗さの影響の計算とフレネルによる反射率の変化の計算という2系統の計算を行い、その2つを乗算して結果としています。
Smoothnessによる粗さの影響
まずはSmoothnessの影響から。RefrectionProbeが登場するのはこちらなので、一応こっちがメインストリームといえそうです。PBRシェーダーのスペキュラーはSmoothnessによって反射する像のボケと反射強度の減衰が行われますが、LWRPのLitシェーダーでは環境光に対してのこの挙動をRefrectionProbeのLODを使って実装しています。ですので最初にSmoothnessからLODレベルを求めるMipという値を作ります。
Mip = (perceptualRoughness / (1.7 - perceptualRoughness * 0.7)) * MipmapLevel
MipmapLevelはReflectionPlobeの持つMipmapの段階なので、その手前までの値が変化する部分という事になります。少し変化のイメージが持ちにくかったのでグラフにしてみました。
perceptualRoughnessと比べて中央付近が少し下がるだけのようです。私だったらそのままperceptualRoughnessを使ってしまいそうです。比較的単純な変化という事がわかりました。MipmapLevelは取得方法が不明だったので、今回は目合わせで固定値8を入れています。MipのShaderGraphは以下です。
続いてもうひとつ、sufaceReductionという値も作ります。こちらはSmoothnessによる反射強度の減衰を表す数値のようです。
sufaceReduction = 1 / (Roughness2 + 1)
これもグラフにしてみました。途中まで直線的で徐々に伸びが緩やかになっています。
ShaderGraphではこうなります。
そしてMipでLODレベルを指定したReflectionPlobeにOcclusionとsurfaceReductionを乗算することで、粗さを考慮した環境光スペキュラーとなります。ShaderGraphは以下。
RefrectionProbeに接続するViewDirectionとNormalVectorは専用のノードがありますが、LWRPのNormalVectorはそのまま使うと頂点間の補間がリニアになってしまい、バーテックスライティングのような見た目になってしまいます。これは間にNormalBlendノードを挟むことによって解決します(NormalMapを使う場合はこのノードが必須になるのであまり問題にならないかもしれませんが)
フレネルによる反射率の変化
続いてフレネルによる影響部分を作っていきます。
これは名前通りのFresnelEffectというノードを作り、SpeculerとgazingTermの間で値を得る(lerp)ことで求められます。
FresnelEffectはViewDirectionとNormalVector、Powerの入力ポートがありますが、Powerはオリジナルのコードを頼りに固定値4を入れています。
ここはShaderGraphを見ると単純に見えますが、関与する値が多くFresnelEffectの計算もちゃんと追っていないので、完全に理解できていない部分です。今後改めて調べてみたいと思います。
ともあれ、先に作ったSmoothnessを考慮したスペキュラーにこのフレネルの影響を乗算することで、環境光スペキュラーが完成します。
環境光の完成
ここまでで作った環境光ディフューズに環境光スペキュラーを加算して、環境光による影響の完成です。モデルに適用すると右のような感じになります。
この後メインライトが加算されるので当然暗いですが、環境光の影響が確認できます。
4.メインライトを計算
環境光の次はメインライトです。
メインライトはシーンに配置された動的なライトなので、ライトのプロパティをいろいろな箇所で参照します。
まずは環境光の時と同様、今後の計算に用いるいくつかの値を作成します。halfDir、NoH、LoHの3つで、シェーダーではおなじみのメンバーです。
halfDir = normalize(ViewDirection + LightVector)
ハーフベクトルです。視点方向とライト方向の和を正規化したもの。
以下のNoHとLoHを求める事にしか使いません。
NoH = saturate(dot(halfDir,Normal))
ハーフベクトルと法線方向のdot積を0-1でクランプしたもの。
クランプはマイナスの値を0にするのが目的ですね。
LoH = saturate(dot(halfDir,LightVector)
ライト方向とハーフベクトルのdot積を0-1でクランプしたもの。
さてここからはこれらを使ってBRDFスペキュラーの近似を求めるようです。
簡易 Cook-Torranceスペキュラー
コードのコメントとWEBからの情報で私が理解できた?のは以下のような内容です。
BRDFspecular = (D * F * V) / 4.0
D = GGXによる法線分布 F = フレネル項 V = 可視性(幾何減衰?)
そしてLWRPのLitShaderではDはそのまま使い、F * V を一度に求める近似式を使って簡略化、さらに全体でまとめられる所をまとめて以下のような式にした、という事なのかなと思います。
BRDFspec = roughness² / (NoH² * (roughness² - 1) + 1)² * LoH² * (roughness * 4.0 + 2.0)
正直仕組みはあまりわかっていないですが(近似ですし)、コードを頼りに上の式をいくつかに分割してShaderGraphで作ります。まずはD項(GGX)から。この部分です。
roughness² / (NoH² * (roughness² - 1) + 1
)² * LoH² * (roughness * 4.0 + 2.0)
次にLoH²。コードではMaxを使って下限を設定しているようなのでそれも再現します。
roughness² / (NoH² * (roughness² - 1) + 1)² * LoH²
* (roughness * 4.0 + 2.0)
さらに(roughness * 4.0 + 2.0)。コードではこの箇所をnormalizationTermと呼んでいました。
roughness² / (NoH² * (roughness² - 1) + 1)² * LoH² * (roughness * 4.0 + 2.0)
各項ができたので、それを用いて以下の形を作り、ここまでをSpecularTermとします。
roughness2 / d² * LoH2 * normalizationTerm
一応このspecularTermのみで出力してみたところ、非常に強いスペキュラーを返しているようでした。
GGXと(簡易)Cook-Torrance。今まで用語としては使ってきましたが、初めて自作しました。
DirectBRDFとradiance、メインライトの完成
DirectBRDF
specularTermにspecularを乗算、さらにdiffuseを加算したものをDirectBRDFとします。
DirectBRDFではスペキュラーは最終状態に近い挙動をしますが、ディフューズには陰影がない状態となります。これは次のradianceで解決します。
radiance
radiance(輝度)はLightVector、LightColor、LightAttenuationとNormalVectorを使って、いわゆるLambertのような計算を行います。ただしここではDiffuseは用いません。ShaderGraphと単体でのイメージは以下になります。
radiance = dot(LightVector,NormalVector) * LightAttenuation * LightColor
メインライトの完成
ここまでで作ったDirectBRDFとradianceを乗算する事で、メインライトの最終結果が得られます。
つまりメインライトでは、Lambertで値が0になる箇所にはスペキュラーも発生しないという事になります。
最終出力
今回はアディショナルライトとエミッションは再現しませんので、環境光とメインライトを加算し、Unlit MasterのColorに接続して完成となります。実行結果は最初の画像のとおりです。
これで今回のシェーダ作成は全て完了です。お疲れ様でした。
ShaderGraphでPBRシェーダーを作ってみて
今回GWの自由研究としてこの試みをやってみたのですが、いろいろとメリットがあったように思います。
まずは既存のShaderを読むコツのようなものが少しわかってきたこと。もともと私は非エンジニアなのでコードを読むのは得意ではないのですが、細かく区切って関数単位で追っていく事で一応読むことが出来ました。そしてShaderGraphでそれを再現する事で、その理解が正しいかをすぐ確かめることができたのは大きなメリットだったと思います。 また大きな区切りの度に直接出力を行い、Unity上でパラメータを変更しながら挙動が確認できたので、各項目がどういう意味を持っているかを感覚的に知れたのも大きかったです。
あとは以前から、ノードグラフは可読性に難があるものだと思っていたのですが、今回のように各項を細かく分割すると計算自体は読みやすくなるのかもと、この記事を書きながら感じました。
ShaderGraphそのものはいくつか追加してほしい機能などもありましたが、全体的にはとても素直な挙動でアーティストにもお勧めできるものだと思います。
ShaderGraphで既存のシェーダーを再現するという方法は、アーティストがシェーダーを理解する事に一定の効果があるように感じましたので、皆さんもお時間があればぜひチャレンジしてみてはいかがでしょう?
追記(サンプル配布)
今回記事で作成したShaderGraphのファイルを配布いたします。
プロジェクトにファイルを追加後、適用するマテリアルのShaderからShader Graphs>PBRgraphを選択することで使用できます。サンプルはUnity2019.2.0b1で作成しましたが、2018でも動くと思います。ShaderGraphまたはPBRシェーダー習得のお役に立てれば幸いです。
再配布、全部/一部の流用など全て自由ですが、不具合への補償、対応は行いませんでのご使用は自己責任でお願いいたします(検証、実験以外への使用はお勧めしません)