AIエージェントにSOLID原則を叩き込んでやろうじゃないか

こんにちは!カヤックボンドの鈴木です。こちらは面白法人グループAdvent Calendar 202524日目の記事です。

サッカーを見ることが好きなのですが、僕の愛する柏レイソルが優勝を逃してしまい、悔しい、歯痒い気持ちを抱えながら記事を書きます。

今回はバイブコーディングにおけるプログラミング原則を徹底させることの影響について書いていきたいと思います。

はじめに

言うまでもないですが、近年AIが見せている凄まじい成長は、常に我々を驚かせ、そして脅かしています。

AIを利用して生産性を向上させる取り組みも業界に限らず見られ、一種の社会現象とも言えるでしょう。

直近だと、我々エンジニアという仕事に最も影響を与えているサービスは、AIによるコーディングエージェントだと思います。

プロンプトを投げるだけでそれに沿ってタスクを進めてくれる技術は、エンジニアが1日で終えることのできる仕事量を大幅に増やしました。

そんな中、僕のような新卒1年目のエンジニアはこうも思うわけです。

「んー、俺がいる意味ってなくね?」

仕様通りに機能が実装されるようにプロンプトを投げて実装してもらい、ある程度動作確認を済ませ、ソースコードを自らレビューし、PRを作成して先輩にレビューしてもらう。

この流れで僕が行なっていると言えるのは「AIの出力をレビューすること」です。

  • ただAIの出力に二重でレビューしているだけ
  • 僕の後にレビューしてもらう先輩は僕の知識や考えのほとんどをカバーしているため、僕が確認する意味がない

こういった感想を抱きました。将来完全に淘汰されるであろうエンジニアの典型ですね。

こんな風に下地のないジュニアが技術の進歩に甘えてAIに依存していると、生産性は向上するけど自分自身は何も変わらないだろうな、と感じました。

そこで、「ソフトウェアエンジニアをやっている限りどんな場面でも基本的に活かすことができるだろう設計思想を学ぼう」という考えに至りました。

自分も学びつつ、アウトプットの質も上げてもらいたいということで、これからエージェントに叩き込んでいこうと思います。

原則を遵守させるとどう変わるか

弊社の開発業務で利用しているClaude Codeを使いながら、試しにSOLID原則を叩き込んでいこうと思います。

SOLID原則についての詳しい説明は省きますが、一言で表すなら

「オブジェクト指向プログラミングにおいて、保守性・拡張性・再利用性の高いソフトウェア設計をするための、5つの基本原則」

です。以下の記事がとても分かりやすいです。

マリオで学ぶSOLID原則

本記事では、ラーメン屋の営業を例に、SOLID原則をClaudeに遵守させてみようと思います。

まずはClaudeに叩き込んでいきましょう。

読み込ませるコンテキスト

SOLID原則に対応して5つのファイルにそれぞれの原則について記しました。

S (Single Responsibility):単一責任の原則

SingleResponsibility.md
# Single Responsibility Principle (SRP) - 厳守ガイドライン

## 基本原則

各コンポーネントは1つの明確な責任のみを持ち、その責任を完全に果たすことに集中する。
複数の責任を持つコードは、必ず分割すること。

## 実装時の厳守ルール

### 1. クラス設計の原則
- 1クラス = 1つの責任
- クラス名は責任を明確に表現する
- 「そして」「また」「さらに」という説明が必要なクラスは分割対象

### 2. 関数・メソッドの原則
- 1関数 = 1つのタスク
- 関数名が動詞+目的語の形で明確に表現できること
- 5-10行を目安とし、最大でも20行以内

### 3. 違反を検知する警告サイン
- クラスに複数の public メソッドグループが存在する
- 異なる理由で変更される可能性がある
- テストが複雑になる
- 「Manager」「Handler」「Processor」などの汎用的な名前

O (Open-Closed):オープン・クローズドの原則

OpenClosed.md
# Open-Closed Principle (OCP) - 厳守ガイドライン

## 基本原則
**ソフトウェアのエンティティ(クラス、モジュール、関数)は拡張に対して開いており(Open)、
修正に対して閉じている(Closed)べきである。**

新機能を追加する際は、既存のコードを変更するのではなく、新しいコードを追加することで実現する。

## 実装時の厳守ルール

### 1. 絶対に避けるべきパターン
- switch文やif-elseチェーンでの型判定
- 既存クラスへの新メソッド追加による機能拡張
- 条件分岐の追加による振る舞いの変更
- 既存メソッドの内部ロジック修正

### 2. 推奨する実装パターン
- 抽象化(インターフェース/抽象クラス)の利用
- 継承よりコンポジション
- Strategy、Template Method、Decoratorパターンの活用
- プラグインアーキテクチャの採用

### 3. 違反を検知する警告サイン
- 新機能追加時に既存ファイルを編集している
- 型やenumでの分岐が増えている
- "もう一つケースを追加"というコメント
- テストケースが既存のものを修正する必要がある

L (Liskov Substitution): リスコフの置換原則

LiskovSubstitution.md
# Liskov Substitution Principle (LSP) - 厳守ガイドライン

## 基本原則
**派生クラスは、その基底クラスと置換可能でなければならない。**

サブタイプ(派生クラス)のオブジェクトは、プログラムの正しさを損なうことなく、
その基底タイプ(親クラス)のオブジェクトと置き換えられるべきである。


## 実装時の厳守ルール

### 1. 契約の保持
- **事前条件を強化してはいけない**(派生クラスは基底クラスより厳しい入力制約を課してはならない)
- **事後条件を弱めてはいけない**(派生クラスは基底クラスが保証する出力を保証しなければならない)
- **不変条件を維持する**(基底クラスの不変条件は派生クラスでも保持される)

### 2. 禁止事項
- 派生クラスで例外を投げる条件を追加しない
- 基底クラスのメソッドを空実装やNotImplementedErrorで上書きしない
- 基底クラスが期待する振る舞いを変更しない
- 基底クラスのメソッドの意味を変えない

### 3. 違反を検知する警告サイン
- 派生クラスで基底クラスのメソッドを使わない/使えない
- instanceof や type() での型チェックが必要
- 派生クラスで「このメソッドは使用しないでください」というコメント
- オーバーライドしたメソッドが全く異なる振る舞いをする

I (Interface Segregation):インターフェイス分離の原則

InterfaceSegregation.md
# Interface Segregation Principle (ISP) - 厳守ガイドライン

## 基本原則
**クライアントは、自分が使用しないメソッドに依存することを強制されるべきではない。**

大きな汎用インターフェースよりも、小さな特定目的のインターフェースを複数作る。
「太った」インターフェースは「痩せた」複数のインターフェースに分割すべきである。

- インターフェースは、実装者の都合ではなく、**使用者(クライアント)の視点**で設計する
- 1つのインターフェース = 1つの役割/責任
- 必要なメソッドだけを公開し、不要な依存を作らない

## 実装時の厳守ルール

### 1. インターフェース設計の原則
- **役割ベースの分離**: 各インターフェースは明確な1つの役割を表現
- **クライアント特化**: 使用者が必要とするメソッドのみを含む
- **高凝集性**: インターフェース内のメソッドは密接に関連
- **依存の最小化**: 不要なメソッドへの依存を排除

### 2. 禁止事項
- 「なんでもできる」万能インターフェースの作成
- 実装を強制するだけの空メソッド
- 異なる責任を持つメソッドの混在
- NotImplementedErrorやUnsupportedOperationExceptionの使用

### 3. 違反を検知する警告サイン
- インターフェースに5つ以上のメソッドがある
- 実装クラスで空実装やエラーを投げるメソッドがある
- 「〜Manager」「〜Handler」などの汎用的な名前
- クライアントが一部のメソッドしか使わない
- メソッド間の関連性が低い

D (Dependency Inversion) 依存性逆転の原則

DependencyInversion.md
# Dependency Inversion Principle (DIP) - 厳守ガイドライン

## 基本原則
**1. 上位モジュールは下位モジュールに依存してはならない。どちらも抽象に依存すべきである。**
**2. 抽象は詳細に依存してはならない。詳細が抽象に依存すべきである。**

依存関係の方向を逆転させ、具象実装ではなく抽象(インターフェース)に依存することで、疎結合で柔軟なシステムを構築する。

- **従来の依存**: ビジネスロジック → データベース(具象に直接依存)❌
- **逆転した依存**: ビジネスロジック → Repository(抽象)← データベース実装 ✅
- 依存の方向は「安定」に向かうべき(抽象は安定、具象は不安定)

## 実装時の厳守ルール

### 1. 依存関係の原則
- **具象クラスを直接インスタンス化しない**(newやコンストラクタ呼び出しを避ける)
- **抽象(インターフェース/抽象クラス)を通じて依存を注入**
- **依存は外部から注入される**(コンストラクタ注入推奨)
- **フレームワーク/ライブラリへの直接依存を避ける**

### 2. 禁止事項
- ビジネスロジック内でのnew演算子使用
- 具象クラスの型を引数や戻り値に使用
- 静的メソッドへの直接呼び出し
- グローバル変数やシングルトンへの直接アクセス
- 外部ライブラリの直接import(ラッパーなしで)

### 3. 違反を検知する警告サイン
- importが具象クラスを指している
- コンストラクタ内でnewを使用している
- テストでモックが困難
- 実装を変更するとビジネスロジックも変更が必要
- 循環依存が発生している

少し長くなってしまいましたが、Claudeに与えるコンテキストにこれら5ファイルを準備しました。 記事に載せる都合もあり、なるべくシンプルにしています。また、それぞれの原則への解釈について、正確ではない表現も含まれていると思いますが、ご容赦ください🙇🏾

CLAUDE.mdにこれら5ファイルを読み込むよう指示を出しています。

タスク依頼時に投げるプロンプトは以下にしました。

# 言語
TypeScript

# 依頼したいタスク
ラーメン屋の営業をオブジェクト指向プログラミングで表現して欲しいです。
客が入店してから、食べ終わって店を出るまでの考えられるユースケースを実装してください。

# 補足
srcディレクトリ内に実装を進めてください。
クラス設計は任せます。

コンテキストによる違いをよりはっきりさせるために、プロンプトに情報をあまり入れていません。

特に遵守させない場合

まずは、コンテキストは何も用意せず、このプロンプトだけを投げてみました。

すると、以下のようなディレクトリで出力してくれました。

src/
├── RamenItem.ts (ラーメンメニュー項目クラス - 名前、価格、説明を管理)
├── Menu.ts (メニュー管理クラス - メニュー項目の追加・検索・表示)
├── Table.ts (テーブル/席管理クラス - 空席確認・着席・退席処理)
├── Customer.ts (顧客クラス - 所持金管理・支払い・食事処理)
├── Order.ts (注文管理クラス - 注文項目・状態管理・合計金額計算)
└── RamenShop.ts (ラーメン屋メインクラス - 店舗全体の営業フロー管理)

適当なプロンプトしか投げていないのだから当然なのですが、いくつか問題点が分かってきました。

まずは単一責任の原則についてです。例えばRamenShopクラスですが、様々な振る舞いを持たされています。お店側が主語となるユースケースを全てこのRamenShopで表現できるようロジックが集約されていました。

RamenShop.ts

setStatus(status: OrderStatus): void {
    // 遷移ルールをここに追加
    if (this.status === OrderStatus.COOKING && status === OrderStatus.CANCELLED) {
        throw new Error('調理中はキャンセルできません');
    }
    if (this.status === OrderStatus.READY && status === OrderStatus.ORDERED) {
        throw new Error('この遷移は不正です');
    }
    // ... 他にも条件が増えていく
    this.status = status;
}

他にも、Orderクラスにある注文状態を設定するsetStatusメソッドを見てみると、条件が増えるたびにifによるチェックが増えていく、OpenClosedの原則に反したものも見られました。

全体的に、「レビューで指摘するほどじゃないけど、ここはこうしてほしいな...」というコードが多く見られました。

リスコフの置換原則、インターフェース分離の原則については、インターフェースもなく継承なども特にしていないため、これらに反するところはないように見えました。

実装の規模からしても、正直なところ原則をしっかりと守る必要はありません。ですが今回は学ぶことが目的なので、原則を含めたコンテキストを読み込ませるとどうなるか、下で見ていきます。

しっかりと遵守させる場合

以下のようなディレクトリ構造で出力してくれました。(ここまでする必要はないというご意見はご容赦ください)

src/
├── domain/ (ドメイン層 - ビジネスロジックの中核)
│   ├── exceptions/ (ドメイン例外 - ビジネスルール違反を表現)
│   ├── models/ (ドメインモデル - エンティティと値オブジェクト)
│   │   └── values/ (値オブジェクト - 不変の値を表現)
│   ├── repositories/ (リポジトリインターフェース - 永続化の抽象)
│   └── services/ (ドメインサービス - 複数エンティティにまたがるロジック)
│       ├── implementations/ (ドメインサービス実装 - ビジネスロジックの実装)
│       └── interfaces/ (ドメインサービスインターフェース - サービスの抽象)
│
├── application/ (アプリケーション層 - ユースケースの実行)
│   ├── dto/ (データ転送オブジェクト - リクエスト/レスポンスの型定義)
│   └── usecases/ (ユースケース - ユーザーの操作フロー)
│       ├── implementations/ (ユースケース実装 - 具体的なフロー処理)
│       └── interfaces/ (ユースケースインターフェース - ユースケースの抽象)
│
└── infrastructure/ (インフラ層 - 技術的な実装詳細)
    ├── factories/ (ファクトリ - オブジェクト生成の責務)
    └── repositories/ (リポジトリ実装 - データ永続化の具体実装)

ぱっと見ですが、めちゃめちゃしっかりしているように見えます。

では原則が徹底されているかどうか、一部を取り上げてみます。

context-EnterStoreUserCase.ts

客が入店するユースケースを表現したクラスについて取り上げます。このクラスは以下の点において、単一責任の原則(Single Responsibility)を果たしています。

  • 単一の責任: 「入店から座席割り当てまで」という1つの明確な責任のみ
  • 変更理由は1つ: 入店フローの仕様変更時のみ変更される
  • 他の責任は委譲:

    • 顧客の生成 → customerFactory
    • データ永続化 → customerRepository
    • 座席割り当てロジック → seatAllocationService
  • 関数は1タスク: execute() メソッドは「入店ユースケースの実行」のみ

context-PaymentService.ts

次に支払いを表現したサービスクラスを取り上げます。このクラスは以下の点において、OpenClosedの原則を果たしています。

  • 拡張に開いている: 新しい支払い方式(クレジットカード決済、電子マネーなど)を追加する場合、IPaymentServiceを実装した新しいクラスを作るだけ
  • 修正に閉じている: 既存のPaymentServiceを変更せずに機能拡張可能
  • switch文を避ける: 支払い方法による分岐は不要。DIコンテナで注入する実装を切り替えるだけ

また、リスコフの置換原則(Liskov Substitution)においても、以下の点で原則を果たしていると言えます。

  • PaymentService の任意の実装と置き換え可能
  • 入力: OrderId, Money → 出力: Payment または例外

    • 例外や事前条件、事後条件についてはインターフェースで契約していないが、守られてはいる
  • 支払い方法が変わっても(現金、クレジットカードなど)、この契約は変わらない

context-ProcessPaumentUseCase.ts

ProcessPaymentUseCaseは、シンプルながらインターフェイス分離の原則と依存性逆転の原則を果たしています。

  • インターフェース分離の原則

    • たった1メソッドの極めて小さなインターフェースに依存
    • 使用しない機能への依存が一切ない
    • クライアント(ProcessPaymentUseCase)の視点で設計されたインターフェース
  • 依存性逆転の原則

    • 具象クラスを直接インスタンス化せず、抽象(IPaymentService)に依存
    • コンストラクタインジェクションで依存を外部から注入
    • 実装の差し替えが容易で、テストが簡単

まとめ

コンテキストにSOLID原則について含めるだけで、コードの品質はだいぶ良くなったと思います。他にもClaude Codeにはagent skillsというコンテキストを圧迫しない方法もあるため、多くの情報を組み込みたい場合に有効です。

途中から参画した案件のコードベースを変えることは難しいかもしれませんが、新規開発が始まってまもない案件、そこまで大きくない案件などでは、自分から既存のコードベースを変える提案、またそれを実行するという意味でも役立つのではないのでしょうか。

コーディングをAIに任せすぎると技術負債が溜まっていくことは危惧されていることではありますが、プロンプトやコンテキストの設計をめんどくさがらずに取り組むことで、ある程度は負債が溜まるリスクを軽減できるでしょう。

また、本記事前半で「自分の成長に繋げたい」と述べました。僕は現在の現場で実際に、上述のコンテキストを用いて実装を進めています。現場のソースコードをベースに、好ましい場合やアンチパターン、またClaudeの出力が本当に正しいかどうかを考えることも含めて、学びに非常に役立っていると実感しています。

kayac.bond

【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