見出し画像

カスタムプロフィールのバッジの仕組みを解説します【Advent Calendar 12/19】

■はじめに

この記事はColorful Palette アドベントカレンダー12/19 の記事です。

株式会社Coloful Paletteでクライアントエンジニアをしているやましょーです。
普段は主に「プロジェクトセカイ カラフルステージ! feat. 初音ミク」(以降プロセカ)のグラフィックス関連の業務に携わっています。

本記事では、プロセカのカスタムプロフィールで使うことができるバッジがどのように描画されているのかを解説します。
プログラムは分からないけれど「カスタムプロフィールのバッジってどうやってるんだろう?」と気になる人の疑問が解消されること、バッジの描画に興味がある人が実際に描画処理を作成できる程度の知識が得られることをコンセプトにしています。
本記事の前半ではバッジ描画の仕組みを図解し、後半ではUnityのShader Graphを用いて実際に描画処理を作成します。

■カスタムプロフィールのバッジとは

プロセカにはカスタムプロフィールという、ユーザが好きに画像やテキストを配置して自身のプロフィール画面を装飾できる機能があります。
その中に、端末本体を傾けると明かりに照らされる角度が変わるギミックを持つ「バッジ」があります。
バッジはユーザの皆さんに「まるで本物の缶バッジみたいだ!」と感じて頂けるよう、見た目がリアルになるようにこだわりました。

以下の動画のバッジは実際のカスタムプロフィールのバッジとは異なりますが、描画の仕組みは同じものになっています。

■開発環境

  • Windows 10 Home 64bit

  • Unity 2021.3.10f1

  • Universal RP 12.1.7

  • Shader Graph 12.1.7

※プロセカの開発環境ではなく、本記事の内容を動作させることができる開発環境です。

■本記事の使用画像について

本記事のバッジの絵柄として使用している画像は、フリーペンシル様のフリーイラスト素材です。
フリーペンシル様のサイト https://iconbu.com

その他のバッジ描画に使用している画像、図解画像、およびキャプチャ画像は筆者が作成したものです。

■前半:バッジ描画の仕組み

前半ではバッジ描画の仕組みをプログラムや数式等を使わず図解していきます。
シェーダ法線マッピングを既に知っているという方は読み飛ばして頂いて大丈夫です。

◆ゲームの世界の見た目を決めているもの

ゲームの世界のモノは、基本的にシェーダと呼ばれるプログラムによってどのような見た目で描画されるのかが制御されています。
これはキャラクターやエフェクトだけでなく、UI(文字、画像、ボタン等)も同様です。

ゲームの世界のモノ自体は同じでも、使うシェーダを変えることによってアニメのような見た目にも現実世界のようなリアルな見た目にもすることができます。

UI標準のシェーダは基本的に元の画像をそのまま描画するようになっていますが、シェーダを作成することで本記事で紹介するようなリアルで立体的なバッジとして描画することもできます。

◆どのようにして人はモノを立体として認識するのか

平面でしかないUIでもシェーダを使うことで立体的に見せることができます。
では、立体的に見せるにはどうしたら良いでしょうか?
それを考えるには、どのようにして人がモノを立体として認識するかを知ることから始まります。

人がモノを立体として認識するには様々な要素が複雑に絡んでいますが、その中には「光の影響による明るい部分と暗い部分がある」という要素があります。

私たちが生活している世界では,ほとんどの場合,光は上からやってきます。光が上からやってくる世界では,出っ張った表面(凸面)があると,上側が光を受けて明るくなり,下側には陰が差します。それに対して,凹んだ面があると,逆に下側に多くの光があたって,上側に陰が差します。脳はこの知識をもっていると考えられています。

6: 空間の知覚 - 知覚・認知心理学 より
https://maruhi.heteml.net/chikakuninchi/?page_id=573

次の2つの画像を見比べてみて下さい。

2つとも現実には画面内に映し出されている平面のはずですが、右の画像は立体的に見えるのではないでしょうか?

右の画像が立体的に見えるのは、脳が持っている知識を逆手に取って、光に多く照らされているように見えてほしい部分を明るく、逆に光にあまり照らされていないように見えてほしい部分を暗くしているためです。

このように、画面内のゲームの世界が立体的に見えるのは、立体だと脳に認識されるように明暗を計算によって作り出していることが要因の一つになっています。
そして、シェーダが基本的にこの明暗を計算する役割を担っています。

◆どのようにして明るい部分と暗い部分を計算するのか

シェーダは明暗を計算することでモノを立体的に見せることができます。
では、明暗を計算するにはどうしたら良いでしょうか?

凄く簡単に言えば、現実世界の光の反射を真似するとリアルで立体的に見える明暗を計算できます。
もちろん、現実世界の物理法則を絶対に真似しないといけないということはないです。
例えばアニメ調のゲームでは、物理法則を真似しつつ、独自の明暗のルールを設けていることが多いです。

カスタムプロフィールのバッジでは、鏡面反射という反射の仕組みを真似しています。
鏡面反射では、光源からモノの表面へ向かう光(入射光)とモノの表面から反射して飛んでいく光(反射光)を考えます。

この時、反射光の向きモノの表面の位置から視点への向きが似ているほど反射光が目に入ってくる量が多い、すなわち、その表面は明るい部分と考えることができます。

カスタムプロフィールのバッジのシェーダでは、画面に映っている表面に対してこのような計算を行うことで明暗を決定しています。

◆表面の向きを変える方法

シェーダでモノの明暗を計算する方法を前項で説明しました。
では、次の動画を見てみて下さい。
平面そのものは空間上に配置されているように見えますが、光の影響による明暗があるはずなのに、表面に凹凸があるようには見えません。

実は平面だとほとんど同じ反射の仕方をするので明るさの強弱にメリハリが出ません。 そして、脳は「強弱が緩やかな明暗ができるのは平面的なモノ」ということを知っています。


では、光の反射の仕方を無理やり変えることができるとしたらどうでしょうか?
有名な方法としてモノの表面の向きを歪めることができる法線マッピングというものがあり、カスタムプロフィールのバッジでも使用しています。

この画像にはバッジの形状のような表面の向きが描かれています。
詳しい仕組みの解説は割愛させて頂きますが、なぜこのような色味になっているのかは分からなくても大丈夫です。
とにかく、この画像を使うことで表面の向きをバッジの形状のように歪めることができます

表面の向きが変わると光の反射の仕方も変わります。
つまり、バッジの形状のような表面の向きへと歪めてしまえば、平面であってもバッジの形状のような反射の仕方をしてくれるという訳です。

これでめでたくUIを立体的に見せることができるようになりました!
ここに画像を上乗せすればカスタムプロフィールのバッジのシェーダの完成です。

◆前半まとめ

  • ゲームの世界のモノはシェーダというプログラムで描画されている!

  • シェーダでモノの明暗を計算することで立体的に見えるようにしている!

  • 鏡面反射という仕組みを使って明暗を計算できる!

  • 表面の向きを歪めてしまえば平面でも立体的に見えるようにできる!

■後半:Shader Graphでバッジシェーダを作る

ここからはゲームエンジンのUnityを使うことができる前提で進んでいきます。
ベクトルや空間といった数学的な用語も登場しますが、本記事ではそれらの解説は行いません。

また、カスタムプロフィールのバッジシェーダと仕組みが同じものを作成していきますが、分かりやすさを重視して最適化等を排除しており、実際に使用されているシェーダとは異なる作りになっています。

◆バッジ描画の準備

まず、Shader Graphで作成したバッジシェーダを適用した状態でUIとして描画するところまで準備します。
最初にバッジシェーダをShader Graphで作成します。
Projectタブ等で、Create > Shader Graph > URP > Sprite Unlit Shader Graphと選択して作成して下さい。
マテリアルも作成してバッジシェーダを割り当てておきます。

次に、シーン上でCanvasを作成します。
CanvasコンポーネントのRenderModeはScreen Space - Camera等にして下さい。
Screen Space - OverlayだとGameビューで正しく描画されません。

Canvasの配下にバッジ用のImageを作成します。
Imageコンポーネントにはバッジシェーダを割り当てたマテリアルをアタッチしておきます。

バッジシェーダのShader Graphの編集画面を開きます。
最低限に画像を描画できるようにしつつ、Alphaクリッピングもできるようにします。

◆バッジの法線マップをワールド空間の法線ベクトルに変換する

事前準備として、シーン上にあるCanvasのCanvasコンポーネントのAdditional Shader ChannlesでNormalとTangentにチェックを付けて下さい。
ここにチェックがついていないと、UIの法線を正しく変換できません。

準備ができたら、バッジの法線マップをワールド空間に変換する処理を作ります。

・法線ベクトルの変換は必要?

UIに法線マップを適用するならば法線マップの値をそのまま適用するような処理にできそうなものですが、UIが回転することも考慮してこのような処理にしています。
実際、カスタムプロフィールのバッジはユーザの操作によって傾けられることがあります。
回転を考慮する場合は、法線マップをオブジェクトに正しく合わせるために、法線、接線、従接線の3ベクトルを使った変換が必要です。

◆PBR(物理ベースレンダリング)の鏡面反射を計算する

PBRの鏡面反射の計算処理を作ります。
InitializeBRDFDataノードとDirectBRDFSpecularノードはSubGraphで作成しています。

InitializeBRDFDataノードのalbedoにはMainTexからサンプリングしたRGBを入力します。
DirectBRDFSpecularのnormalには先ほど変換したワールド空間の法線ベクトルを入力します。
LightDir1の初期値は0ベクトルにさえならなければ何でも良いですが、(2, 3, -1)等にすると画像のようなプレビューを得られます。

・BRDFとは?

BRDFというあまり聞きなれない単語が登場しました。
BRDFは日本語では双方向反射率分布関数と言います。
何やら難しそうですが要するに、物体表面での光の反射率を求める関数です。
「どのようにして明るい部分と暗い部分を計算するのか」で説明した鏡面反射や、「どうして光源を3つ用意するの?」で後述する拡散反射もBRDFの一種です。

ちなみに、BRDFを更に一般化した関数として、BSSRDF(双方向散乱面反射率分布関数)というものがあります。
こちらは物体内部へ光が入って散乱し、入射した表面とは違う表面から光が出ていくことも加味した反射率を求める関数です。
BSSRDFであれば、灯りに手をかざした時に手の水掻きなどが赤く透ける現象(表面下散乱)等も表現することができます。

・InitializeBRDFDataノード

InitializeBRDFDataノードでは、BRDFに必要なパラメータを算出しています。
この処理をCustom Functionノードでtype:FileにしてHLSLを使って直接記述する場合、以下のようになります。

void InitializeBRDFData_float(
    half3 albedo,
    float metallic,
    float smoothness,
    out half3 brdfDiffuse,
    out half3 brdfSpecular,
    out float brdfGrazingTerm,
    out float brdfRoughness2,
    out float brdfRoughness2MinusOne,
    out float brdfNormalizationTerm)
{
    float oneMinusReflectivity = 0.96 - metallic * 0.96;
    float reflectivity = 1.0 - oneMinusReflectivity;
    float roughness = 1.0 - smoothness;
    roughness = max(roughness * roughness, 0.0078125);

    brdfDiffuse = albedo * oneMinusReflectivity;
    brdfSpecular = lerp(0.04, albedo, metallic);
    brdfGrazingTerm = saturate(smoothness + reflectivity);
    brdfRoughness2 = max(roughness * roughness, 0.0078125);
    brdfRoughness2MinusOne = brdfRoughness2 - 1.0;
    brdfNormalizationTerm = roughness * 4.0 + 2.0;
}

この処理はBRDF.hlslのInitializeBRDFData関数を展開したものです。
シェーダをShader Graphを使わずにHLSLで直接記述する場合はそちらを使用して下さい。

・DirectBRDFSpecularノード

DirectBRDFSpecularノードでは、BRDFを使って直接光の鏡面反射を算出しています。
この処理をCustom Functionノードでtype:FileにしてHLSLを使って直接記述する場合、以下のようになります。

void DirectBRDFSpecular_float(
    float3 normal,
    float3 lightDir,
    float3 viewDir,
    float brdfRoughness2,
    float brdfRoughness2MinusOne,
    float brdfNormalizationTerm,
    out float specular)
{
    float3 normalizedLightDir = normalize(lightDir);
    float3 normalizedViewDir = normalize(viewDir);
    float3 halfDir = normalize(normalizedLightDir + normalizedViewDir);

    float NoH = saturate(dot(normal, halfDir));
    float LoH = saturate(dot(normalizedLightDir, halfDir));
    
    float d = NoH * NoH * brdfRoughness2MinusOne + 1.00001f;
    float LoH2 = LoH * LoH;
    specular = brdfRoughness2 / (d * d * max(0.1h, LoH2) * brdfNormalizationTerm);
}

この処理はBRDF.hlslのDirectBRDFSpecular関数を展開したものです。シェーダをShader Graphを使わずにHLSLで直接記述する場合はそちらを使用して下さい。

ちなみに、DirectBRDFSpecular関数で使用されている鏡面反射はGGXという計算モデルです。
PBRを詳しく調べたい方は参考にして下さい。

◆光源を3つ用意してライティングする

先ほどは光源からの向きを一つ用意してライティングしましたが、今度は光源からの向きを三つ用意してライティングします。

InitializeBRDFDataノードはそのまま使えますが、DirectBRDFSpecularノードには異なるライトベクトルを入れる必要があるので、DirectBRDFSpecularノードを3つに複製します。
ライトベクトルで鏡面反射を算出したらそれぞれ値を絞っておき、加算合成した時に白飛びしないようにします。
最後に、InitializedBRDFDataの出力にあるbrdfDiffuseと鏡面反射の3つのspecularを全部加算合成すればバッジの基本的なライティングは完了です。

ライティング結果はFragmentノードのBase Colorに繋ぎます。

マテリアルのプロパティの値はお好みでどうぞ。

これでカスタムプロフィールのバッジシェーダができました。

・どうして光源を3つ用意するの?

光源を3つ用意した理由が気になる方もいると思います。
その理由は、光源が1つだと極端に端末を傾けた時にライティングされない面積が増えて立体的に見えなくなってしまうからです。
カスタムプロフィールでは端末の角度に合わせて光源の向きも変わるようにしています。 すると次の画像のように、光が当たっている部分が小さくなってしまうような場合も発生します。

「◆どのようにして人はモノを立体として認識するのか」の項目で解説したように、「光の影響による明るい部分と暗い部分がある」と立体的に見える一方、逆にそうでない部分は平面的に見えてしまいます。
「光の影響による暗い部分があれば立体的に見えるのでは?」と考える方もいると思います。
鏡面反射だけでなく拡散反射も導入すれば、暗い部分を作り出すことは簡単です。

拡散反射とは光源の向きと表面の法線の向きによる反射です。
法線が光源の方を向いているほど明るくなり、逆に向いていないと暗くなります。

この拡散反射を導入してバッジを描画すると次のようになります。

極端な向きの光源だと拡散反射はこのようになってしまいます。
ほとんどの領域が暗くなるので結局立体的に見えないどころか、キャラクターのイラストが黒く塗り潰されたかのようになってしまい非常にイケてないです。
このような作用が避けられないので拡散反射は導入しませんでした。

その後も解決方法を模索した結果、単純に、暗くするのではなく明るくすれば良いという結論に至りました。
明るい場所を複数用意することで、次の画像のように立体的に見える範囲を広げることができた他、複数の光源が存在している室内でバッジを鑑賞している感も引き出すことができました。

そして光源が2個でもなく4個でもなく3個なのは、2個だと回転の条件によっては平面的に見えてしまう部分を補いきれないからで、4個だとライティングの計算コストが流石に高いからです。

◆後半まとめ

  • UIで法線を計算するシェーダを使う時はCanvasのRenderModeとAdditional Shader Channelsの設定に注意!

  • オブジェクトが回転する場合の法線ベクトルの空間変換には、法線、接線、従接線をしっかり使うこと!

  • バッジシェーダではPBRの鏡面反射を使用してライティングしている!

  • バッジシェーダでは回転させても平面に見えないように光源を3つ配置している!

■おわりに

本記事では、カスタムプロフィールのバッジがリアルに見える仕組みを解説しました。
「あのバッジってこんな工夫を凝らしているんだ!」と思って頂ければ幸いです。
また、少しでもゲームのグラフィックスについて興味が湧いたという方がいらっしゃれば良いなと思います。

一方、既にグラフィックスの処理を実装する方にとっては割と一般的な内容だったかと思います。
今後、いつか技術コテコテの記事も書いてみたいと考えているので楽しみにして下さい!

次回はバックエンドエンジニアの、のきなさんが担当します。
Colorful Palette アドベントカレンダー は12/25までの平日+α更新となっております。
ぜひ引き続きご覧ください!

みんなにも読んでほしいですか?

オススメした記事はフォロワーのタイムラインに表示されます!