【Unity】URP x 宝石シェーダー

面白法人グループ Advent Calendar 2025 の23日目の記事になります。
こんにちは!ハイパーカジュアルゲームチーム・エンジニアの深澤です。とあるハイパーカジュアルゲームの実装で宝石のシェーダーを加える機会があったので、そのご紹介になります。

サンプルは以下のリポジトリにアップしました。Unity 2022.3.69f1 の URP です。

今回のシェーダーは以下のアセットをベースに実装させていただきました。ありがとうございます。詳しくは後述させていただきます。
【Unity】茜式宝石シェーダー(akanevrc_JewelShader)【VRChat対応】 - 茜の道具屋さん - BOOTH

はじめに

リアルタイムレンダリングにおいて、屈折を必要とするオブジェクトの描画は難儀します。単純に「透ける」だけであればGPUの機能として透過やブレンディングを使うことができるのですが、屈折のように「曲げる」見た目の実装はケースによりアプローチが変わります。

まず水面の描画を考えてみます。水面近くに存在している水中のオブジェクトが揺らいだり曲がって見えると水面っぽさを感じると思います。つまり、1回の屈折で水らしい見た目に近づくはずです。実装的には例えば、水面以外のシーンを描画してから水面のメッシュを描画する順番にし、描画済みシーンのテクスチャを水面を描画するシェーダーに渡して、そのテクスチャを法線やノイズマップなどによってサンプリングする位置をずらす方法が考えられます。完璧な屈折のシミュレーションとはいきませんが曲がって見えるのでそれっぽくなります。

しかし、宝石のように2回3回と複数回の反射や屈折を繰り返すような複雑な見た目を再現したい場合、本来の宝石を考えると周囲の光が入ってきているはずなので、描画済みシーンの画像のような背後の画像だけでは情報量が足りません。

パストレーシングの方法を使うと根本的な解決に近づくはずですが、かなりの高負荷になってしまいます。ハイパーカジュアルゲームのような全世界のユーザーを想定としたモバイル端末向けのゲームということを考えると、なおのこと負荷はできるだけ軽い方が嬉しいです。


そうして調査を進めていると、冒頭でご紹介したアセットを見つけました。

「1パスで描画」という記載があり実際に中身を見てみたところ、パストレーシングなどを使わずオブジェクトごとのシェーダーで解決しており負荷的にまさに理想の方法でした。参考アセットはVRChatを想定したBuiltin-Pipeline向けの実装となっています。ライセンスがCC0となっており、アセットの中身を参考にURP向けに実装しつつ自分なりの改造をさせていただきました。

どういう考え方で複数回の反射を実現しているか、そしてどのような実装・改造を行なったかを説明させていただきます。

実装方針

複数回反射そのものの流れを考えてみます。「レイを作成し物体内部に侵入してから、最大反射回数に到達する or 外に射出されるまでループを繰り返す」ことをしていきます。物体内部に侵入した光は、再度物体内部に反射をするか物体の外に出ていくかがフレネルの計算によって決まるためです。

// 擬似コード
float3 color;
Ray ray;
float isReflect = 1.;
for(int i = 0; i < iterateNum; i++) {
  if(isReflect) {
    color += CastRay(ray, isReflect); // 反射を計算。また、フレネルに応じて反射か屈折かを判断。
  }
}

そして、参考アセットでの複数回反射の流れはおおまかに4ステップに分かれています。

  1. あらかじめメッシュの法線情報を格納したCubeMapを作成しておく
  2. ピクセルシェーダーで視点から物体内に侵入するレイを作成する
  3. レイの内部反射と射出を判定。1のCubeMapを読み取り「だいたいの反射/射出位置の座標および法線」を求める
  4. 3の法線を元にシーンの環境マップを読み取って加算。内部反射なら3,4を繰り返す

3,4が前述のループの CastRay の中で行なっていることです。1つ1つ見ていきます。

1. メッシュの法線情報を格納したCubeMapの生成

参考アセットではメッシュの法線情報を焼いたCubeMapを生成するツールを提供してくれています。以下のようなシェーダーを用いてCubeMapに焼いています。
RGBA8 format に焼くため、法線を0.5倍して0.5足し、-1~1->0~1に変換して格納します。floatなテクスチャであればこの変換は必要ないのですが容量が増えてしまいます。

v2f vert(appdata v)
{
    v2f o;
    o.vertex = UnityObjectToClipPos(v.vertex);
    o.normal = UnityObjectToWorldNormal(v.normal);
    return o;
}

fixed4 frag(v2f i) : SV_Target
{
    return saturate(fixed4(i.normal, 1) * 0.5 + 0.5);
}

こちらのキャプチャはUnity標準のBoxを元に作成したCubeMapです。

2. 視点からメッシュに侵入するレイの作成

カメラ座標からメッシュのピクセルの座標へ向かうベクトルになります。

float3 E = _WorldSpaceCameraPos;
float3 inP = input.positionWS; // 入射位置
float3 N = normalize(input.normalWS);
float3 PtoE = normalize(E - inP);
float3 inDir = -PtoE; // 入射ベクトル

3. レイの内部反射と射出の判定

ここが実装の肝です。「物体の中心で反射したベクトルを求めると、だいたいの射出位置/法線になる」というものです。

  • 中心の位置を任意に定める(Cubemapを焼いた中心座標。メッシュを原点に置いてCubemapを作成していれば0,0,0)
  • レイの入射ベクトル、入射位置、中心の位置の3つを元を射出する近似位置を定める
  • 近似位置と中心からCubeMap参照用のベクトル(CubeMapに焼いた法線)を算出

以下、疑似コードです。

float3 outP = dot(center - inP) * 2 * inDir; // 近似した射出位置
float3 cubeDir = normalize(outP - center); // Cubemap参照ベクトル
float3 outNormal = texCube(bakedCubemap, cubeDir).xyz * 2. - 1.; // 法線: -1~1を0~1に変換して格納しているので-1~1に戻す

この方法は特に宝石のような対照になりやすい形をしたメッシュに向いていそうです。

4. 法線を元にシーンの環境マップを読み取り加算。3,4を繰り返す。

3で求めた法線で環境マップの色を読み取り、反射時点の色とします。そして内部に反射するベクトルを計算し、3,4を繰り返し色を加算していきます。

反射のたびにフレネルの法則に応じてまた物体内部に反射する光と物体外に射出する光に分かれるので、その量を色の加算に重み付けしてあげることによって反射による光の減衰を表現します。
以下は、複数回反射の回数を0~8回まで変えていったものです。反射の回数が増えるたびに色が加算されています。


以上のような流れで複数回内部反射の見た目を実現しています。

反射のたびにシーンの環境マップの色を加算していく方法なので、動かないオブジェクトについてはライティングのベイクをしてシーンの環境マップの情報量を増やすことでリッチな見た目に近づいていきそうですね。

分光

虹やプリズムのように色の要素が分かれて見える現象の再現です。光は色の成分によって波長が違い、曲がりやすさが変わります。赤〜紫はそれぞれ波長が長〜短の性質をもち、より波長が長い赤のほうが直進しやすく曲がりにくいです。

シェーダーでは、RGBそれぞれの要素別で光の経路計算をして最終的に合成するという方法を取ります。ベクトルを屈折させる関数 refractに渡すIOR(屈折率)をRGBごとにちょっとずらしてあげることで曲がり具合が変わりサンプリングする色がRGBごとに少しズレるので、分光のような表現が可能になります。

ex)
赤: refract(dir, normal, ior + .01);
緑: refract(dir, normal, ior);
青: refract(dir, normal, ior - .01);

色成分ごとのパラメーター

そのかわり経路計算が3倍になり、単純計算で負荷も3倍に増えます。

// 色成分の要素ごとに探索回数分のループが走るため3倍
float colorR = CastRayIterate(ray, IOR + 0.01);
float colorG = CastRayIterate(ray, IOR);
float colorB = CastRayIterate(ray, IOR - 0.01);

以下はIORをずらす量を変更していったものです。

吸収

宝石の色が違う理由は宝石によって色成分の吸収率が違うためです。吸収率が低い色成分ほど宝石の色として強く出ます。例えばルビーは赤色なので、赤色成分の吸収率が低いことになります。

シェーダーでは exp(-kx) を減衰具合として計算します。色の算出はRGBごとに色成分 * exp(- 吸収率 * 距離) となります。
吸収率が低いほど減衰が弱くなるので、その色成分が出やすくなる実装です。

色成分ごとのパラメーター

exp(-kx) のグラフ

Graph created with Desmos. Licensed under CC BY-SA 4.0

こちらは赤の色成分の吸収率を下げてルビーのような見た目にしたものです。

サファイヤ、エメラルドのような色も追加してみました。

逆に吸収率をマイナスにすると増幅し自己発光します。光らせるためにポスプロのブルームも有効にしておきます。本来宝石は自己発光しませんがCGならではですね。発光するので屈折感はどうしても分かりづらくなります。

Graph created with Desmos. Licensed under CC BY-SA 4.0

ダミーの光源

上記のように宝石そのものは発光しないので、擬似的に光源を追加して光を足してあげます。
参考アセットでは擬似光源を4つまで入れられるようになっていました。自分の実装では2つにし、反射ごとに光源に対してのDiffuse成分を計算して加算しています。

こちらは赤色の擬似光源の位置を変更したり、2つ目の擬似光源の色を白から青に変えている様子です。

CubeMap生成の改造

生成するCubeMapに法線の情報だけでなく、中心からの距離を格納するようにしてみます。主に、光の距離減衰用のパラメーターとして用いるためです。
焼くパラメーターを以下のように変更します。xyに法線を、zに中心からの距離をboundsの大きさで割ったもの、wにboundsの大きさの逆数を入れます。
中心からの距離自体はローカル座標のベクトルの長さとなるのですが、textureのformatがRGBA8なので0-1に値をまとめる必要があるため取り出す際にzとwを使って中心からの距離を復元させます。

// CubeMapに焼くコードの変更
// xy: 法線のxyのみpackする
float2 xy = saturate(i.normal.xy * .5 + .5);
// z: 中心からの距離
float z = length(i.vertex.xyz) / _BoundsScale;
// w: boundsの逆数. boundsは均等な大きさに制限
float w = 1. / _BoundsScale;
return fixed4(xy, z, w);

法線はとある2軸の情報があれば残りの軸も算出可能です。ただしこの法線復元のための計算量はループの回数分増えてしまいます。

// xy成分だけ持つベクトルから法線を復元
float2 decodedNormalXY = cubeColor.xy * 2. - 1.;
float decodedZ = sqrt(max(0., 1. - dot(decodedNormalXY, decodedNormalXY)));
float3 localOutNormal = normalize(float3(decodedNormalXY, decodedZ));

色の加算の距離減衰に適応してみたところ、このメッシュの形状では若干明るくなるぐらいでした。右下あたりのハイライト強めの面では差が出やすくなっていました。

最後に

参考アセットのおかげで大変勉強になりました。ありがとうございます。改めて、実装したリポジトリはこちらになります。

参考

【Unity】茜式宝石シェーダー(akanevrc_JewelShader)【VRChat対応】 - 茜の道具屋さん - BOOTH