【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

ミトコンドリアの育て方

本記事は、面白法人グループ Advent Calendar 2025 の22日目の記事です。

こんにちは!カヤック技術部の千葉です。2回目の投稿です。

今回は「2ヶ月ちょいで3kmを1分速く走れるようになる方法」以来3年ぶりですが、ランニングネタを投下していきたいと思います。

ランニング近況

2020年秋の健康診断で「脂質代謝異常」と判定されたのを機に走り始め、早いもので丸5年が経過しました。

始めた当初はキロ7分ペースで3kmも走れませんでしたが、2023年の第18回湘南国際マラソンでは3時間27分54秒を記録し、サブ3.5を達成。現在はサブ3達成を目指して日々トレーニングに励んでいます。

昨年は怪我で2ヶ月間走れず、走行距離も伸ばせず悔しい結果に終わりましたが、今年は大きな怪我もなく、年間走行距離3,000kmに到達しそうです。おかげさまで、今年の健康診断ではカヤックに入社して以来、初めて「A判定」になりました!

2025年走行距離

今年は6大会出走しました。

  • 4月 多摩川ハーフマラソン 1:35:57(自己ベスト)
  • 5月 リレーランカーニバルハマスタ 3時間リレー 47周(1周1km)
  • 10月 横浜マラソン2025 3:26:45(自己ベスト)
  • 11月 第14回 NIPPON IT チャリティ駅伝(3km x 5) 57:58
  • 11月 第12回 青葉区民マラソン(10km) 39:46(2ndベスト)
  • 12月 第20回 湘南国際マラソン 3:48:44

リレーランカーニバルハマスタ 3時間リレーのメンバー

第14回 NIPPON IT チャリティ駅伝 今年は4チーム参加

第20回湘南国際マラソンで今年も42.195km採用を実施。今年50歳のkozoが3時間21分8秒でサブ3.5達成!

9月から12月の湘南国際マラソンに向けてトレーニングを重ねてきました。 直前の10km大会の記録や、スマートウォッチのVO2Maxから設定した目標タイムは3時間10〜15分。しかし本番では、ハーフ地点直前で脇腹に激痛が走り、大幅なペースダウンを余儀なくされました。 異変が起きてからは水分以外の補給も受け付けない状態でしたが、それでも完走できたのは、今年のトレーニングで重要視していた「ミトコンドリアのエネルギー生産力」のおかげかもしれません。

マラソンにおけるミトコンドリアの役割

細胞内に存在するミトコンドリアは、糖や脂肪を代謝してエネルギー(ATP)を作り出す「細胞の発電所」のような役割を担っています。

ランニングの消費カロリーは「体重(kg) × 走行距離(km)」で概算でき、体重70kgの人がフルマラソンを走る場合、約3,000kcal(70kg × 42.195km)を消費します。しかし、体内に蓄積できる糖質(グリコーゲン)は最大でも2,000kcal(約500g)程度に過ぎません。そのため、フルマラソンを完走するには糖質だけでなく、体内に豊富にある脂質をいかにエネルギーとして活用できるかが鍵となります。この脂質を効率よく燃焼させるために不可欠なのがミトコンドリアです。

ミトコンドリアの量を増やし、その質(代謝能力)を高めることは、脂肪燃焼効率を向上させ、後半の失速を防ぐための重要なトレーニング課題と言えます。

ミトコンドリアを育てる2つのトレーニング

ランニングでミトコンドリアを効率よく育てるには、「ゾーン2トレーニング」と「高強度インターバルトレーニング(HIIT)」を組み合わせるのが有効です。

トレーニング強度の指標として、最大心拍数を5つの段階に分けた「心拍ゾーンモデル」が役立ちます。それぞれの強度が身体に与える影響は以下の通りです。

ゾーン . 運動強度 . 最大心拍数の割合 期待できる効果
5 全力 90〜100% HIITなど。ミトコンドリアの「質」の改善、スピード向上
4 強い 80〜90% 無酸素作業閾値(AT値)の向上
3 70〜80% 有酸素能力・血液循環の改善
2 軽い 60〜70% ミトコンドリアの「量」の増加、脂肪燃焼効率の改善
1 とても軽い 50〜60% ウォーミングアップ、疲労回復

※最大心拍数は「220 - 年齢」で簡易計算できますが、個人差が大きいため、実測値(全力疾走時の数値など)を用いるのが理想的です。私の場合、実測で190以上まで上がるため、その数値を基準に設定しています。

ゾーン2トレーニング

ゾーン2とは、最大心拍数の60~70%程度の低強度トレーニングを指します。心拍計がない場合は「走りながら楽に会話が続けられるペース」が目安です。

この強度で長時間(最低45分、理想的には90〜120分以上)走り続けることで、細胞内のミトコンドリアの量を効率的に増やすことができます。また、このゾーンは脂肪を優先的にエネルギーとして使う「脂質代謝」の効率が最も良く、毛細血管の発達も促されるため、フルマラソンに必要な土台(有酸素ベース)を作るのに最適です。

少し心拍数高いですが...

高強度インターバルトレーニング (HIIT)

HIITとは、最大心拍数90%以上の「きつい」と感じる高強度運動と、短い休息を繰り返すトレーニングです。ランニングにおいては、400m以上のインターバル走を10本程度行うメニューがおすすめです。 なぜ400mなのか。それは、短すぎると心拍数が上がりきる前に走り終えてしまうからです。400mをしっかり追い込み、休息を短く設定して繰り返すことで、心拍数をターゲットゾーンまで引き上げ、VO2max(最大酸素摂取量)の向上やミトコンドリアの活性化を促します。400m走っても心拍数が上がりきらない場合は、リカバリー(休憩)をジョギングでつないだり、休憩時間を短く設定したり、あるいは走行距離を延ばすなどの調整をします。

400m x 10 + 1km + WS

注意点としては、ミトコンドリアを効率よく育てるには順番が大切です。まずは「ゾーン2トレーニング」でミトコンドリアの量を増やし、その土台の上にHIITを加えて質を高めていく。この「量から質へ」の流れを意識することで、怪我を防ぎながら効率的にパフォーマンスを向上させることができます。

トレーニング例

9月から11月にかけて取り組んだトレーニング内容を紹介します。

9月

ミトコンドリアを増やして土台を作ろう!

  • 水曜: 100m x 10 (スピード強化)
  • 木曜: 会社で坂ダッシュ or ジョグ
  • 土曜: テンポ走
  • 日曜: ジョグ or 低強度ロング
  • その他: ジョグ

10月

ミトコンドリアを増やしつつ質も高めていく

  • 水曜: 400m x 10 インターバル
  • 土曜: テンポ走 or 大会前日刺激
  • 日曜: 低強度ロング or 大会
  • その他: ジョグ

11 月

最後の仕上げ

  • 火曜: 400m x 10 インターバル
  • 水曜: 800m x 5〜8 インターバル
  • 土曜: テンポ走 or 大会前日刺激
  • 日曜: 低強度ロング or 大会
  • その他: ジョグ

最後に

湘南国際マラソン本番では、予期せぬアクシデントによりペースダウンを余儀なくされ、自己ベスト更新できなかったことは非常に悔やまれますが、ミトコンドリアのエネルギー生産力で、なんとか完走できました。今回の経験を糧に来年またリベンジを果たしたいと思います。

今回、ミトコンドリアを育てるトレーニングで得られた最大の収穫は、特別な食事制限をすることなく体脂肪率が劇的に減少したことです。調整前は16%台だった体脂肪率が、レース直前には11%台まで落ちました。

体脂肪率減は大収穫

体脂肪率の低下は、マラソンのタイム向上に直結するので、この「脂肪を燃やしやすい体」を維持していきたいと思います!

最後に、カヤックでは一緒にミトコンドリアを育てたい方も、そうでない方も絶賛募集中です!