本記事は CyberAgent Developers Advent Calendar 2021 23日目の記事です。

はじめに

こんにちは。ゲーム&エンターテイメント事業部の矢野 (@harumak_11) です。
SGEコア技術本部(通称コアテク)というところで共通ライブラリを作ったり横断的な活動をしています。

さてコアテクでは先日、 NOVAシェーダ というプロダクトをOSSとしてリリースしました。

NOVA Shader

これはUnityのURPで使える、Particle Systemのためのシェーダです。
エフェクトを作るときによく使う機能が一つのシェーダにまとめて実装されており、
これらの機能を組み合わせることでさまざまな表現を効率的に作れます。

NOVA Shaderの機能

 

本記事ではこのシェーダが生まれた背景とNOVAシェーダの開発プロセス、そしてシェーダ共通化技術についてまとめます。

シェーダが生まれた背景

さて、実はNOVAシェーダには前身となるシェーダが存在します。
NOVAシェーダが生まれた背景を説明するため、まずはこのシェーダが生まれた時のお話をまとめます。

ことの発端は数年前に遡ります。
当時私が参画したプロジェクトでは、機能の微妙に異なる多数のシェーダが乱立していました。
プロジェクトはまだまだ開発途中であるにもかかわらず、エフェクトシェーダの数はすでに50を超えていました。

なぜこのような状況になったのでしょうか。
実際にイチからシェーダを開発するプロセスを想定しながら考えてみます。

まず、エフェクトシェーダにおいてブレンドモードは重要です。
そこで最初に「加算」「乗算」「アルファブレンド」というブレンド方法が異なるシェーダを作ります。

 

ブレンド方法

次に、頻繁に使う機能といえばUVスクロールです。
そのため、各ブレンド方法ごとにUVスクロール機能を持ったシェーダを作ります。

UVスクロールを追加

ここまでで6個のシェーダファイルが出来上がりました。

さて、今のままではシェーダのデフォルト設定により、モデルの表面しか描画されません。
Particle Systemでは、モデルの裏面から見ても描画されていてほしいケースは多々あります。

そこで上記の6種類に対して「表面」「裏面」「両面」を描画できるシェーダをそれぞれ作成します。

カリングモードを追加

こうしてあっという間に18個のシェーダファイルが出来上がりました。
このように、組み合わせる機能が多いほどシェーダの数は指数関数的に増加していきます。
結果的に、50個を超えるシェーダが開発されることになりました。

ここで、前述の通りこのプロジェクトはこの時点ではまだまだ開発途中でした。
今後の機能追加を考えると、数多くのシェーダを管理するコストが大きな技術的負債になることは火を見るよりも明らかでした。

プロジェクト進行度と技術的負債の大きさ

このような経緯から、汎用的な機能をまとめたエフェクトシェーダを開発することに決めました。

NOVAシェーダの開発プロセス

次に、実際にNOVAシェーダを開発する際に採ったプロセスについてまとめます。

まずは要望を出し切る

シェーダの仕様を決める上で、まずは要望を全て洗い出す必要があります。
これはエンジニアが考えて答えが出る問題ではありません。
そこで、社内のVFXアーティストの方々に協力していただきました。

今回のNOVAシェーダの場合、前述の通り前身となるシェーダが存在していたため、
このシェーダにない機能で、あったら便利な機能を挙げていただく形にしました。

このプロセスで重要なのは実現可能性はひとまず考えず、とにかく要望を洗い出すことです。
結果、回転やグラデーションマップなど、多くの機能がこのプロセスから生まれました。

グラデーションマップの仕様策定用資料
グラデーションマップの仕様策定用資料

コンセプトに沿わない機能は捨てる

さて、プロダクトを作る上で、コンセプトはプロダクトの魂ともいえるほどに重要です。
いくら機能が多くても、難しい技術を使っていても、コンセプトに沿わない機能であればプロダクトの魅力が薄れます。

NOVAシェーダは、あくまでエフェクト開発でよく使う汎用的な機能をまとめたシェーダです。
決してどんな表現でも作ることのできる万能シェーダを目指しているのではありません。
特定の表現にしか使わない機能は専用のシェーダを作るべきという思想です。

そこで次のプロセスとして、前節で並べた機能のうち、この思想に沿っていないものを削っていくことにしました。
結果的にいくつかの機能を削ることができました。

例えば、NOVAシェーダにはMirror Samplingという機能があります。

ミラーサンプリングの仕様策定用資料
ミラーサンプリングの仕様策定用資料

これはUnityのインラインサンプラーステートを使用して実現しているため、
技術的にはMirror以外のサンプリング方法も指定できたり、テクスチャのフィルタリングモードを上書きする機能を作ることもできます。
実装工数もミラーサンプリングのついでに実装してしまえる程度のものです。

しかしながら使用頻度が低い上に、シェーダの理解や使用の難易度が上がってしまうため、これらの機能は削除しました。

構造化

実装すべき機能が過不足なく決まったら、次にそれらの機能を構成要素に分解し、整理して再構築するプロセスが必要です。
例えば今回、「ディゾルブで削りつつ、削ったエッジを光らせたい」という要望がありました。

ディゾルブ
ディゾルブ

またそれとは別に、「指定した部分を指定した色でエミッション(発光)させたい 」という要望がありました。

エミッション
エミッション

ここで、これらの要望は、「発光させたい」という共通点を持っています。
したがって、この部分は本質的に同じ機能であると考えられます。
結果、ディゾルブ機能は単純に削るだけの仕様に留め、エミッション機能に「エッジだけを対象に適用する」オプションを追加しました。

ディゾルブとエミッション

 

このような構造化プロセスにより、仕様がシンプルになり使いやすくなりました。

実装とイテレーション

ここまでで要望を使用に落とし込むことができたので、あとは実装をするだけです。
メンテナンス性や拡張性を踏まえた設計をしてコードに落とし込んでいきます。

実装が完了したらそれで終わりではなく、実際に使ってもらうことが重要です。
いくら事前に仕様を固めていても、実際に使ってみたら改善点やさらなる要望が出てくるものです。

今回のNOVAシェーダでも、実装して改善するイテレーションを複数回実施して、
ようやく納得のいく機能や使い勝手が実現できました。

とはいえ、まだまだ改善の余地はあると考えています。
ご意見やご要望などありましたお気軽に IssuePull Request を投げていただければと思います。

シェーダ共通化テクニックまとめ

さてここまで技術的な話が少なかったので、最後に今回NOVAシェーダで使った、
シェーダを共通化するための技術的なテクニックについて簡単にまとめて終わります。

shader_featureとmulti_compile

shader_featureやmulti_compileはシェーダを共通化する上で必要不可欠な機能です。
例えば以下のように書くと、Unityは内部的に赤色、緑色、青色を表示する3つのシェーダ(シェーダバリアント)を生成します。

// キーワードRED, GREEN, BLUEを定義
#pragma multi_compile RED GREEN BLUE

// (中略)

// フラグメントシェーダ
half4 frag(Varyings IN) : SV_Target
{
    // キーワード毎に処理を分岐
   #ifdef RED
        return half4(1, 0, 0, 1);
   #elif GREEN
        return half4(0, 1, 0, 1);
   #elif BLUE
        return half4(0, 0, 1, 1);
   #endif
}

キーワードはMaterial.EnableKeyword()などのAPIを使ってスクリプトから切り替えられます。
有効になっているキーワードの組み合わせによって、使用するシェーダバリアントが決定されます。

Material mat;
mat.EnableKeyword("RED");

また、multi_compileの代わりにshader_featureを使うと、実際にプロジェクトで使用されているキーワードに基づいて、必要なシェーダバリアントのみが生成されます。
ビルドに入れるシェーダの容量はできる限り小さくしたいため、動的に有効化しないキーワードにはshader_featureを使うことが推奨されます。

このような機能により、一つのシェーダファイルで複数の機能の組み合わせをもつシェーダバリアントを作成することができます。

ブレンドモードをシェーダの外から設定する

UnityのShaderLabでは、加算や乗算といったブレンドモードを Blend Command により指定します。
例えば以下のように指定すれば、ここまでの描画結果とシェーダによる結果が加算ブレンドされます。

Blend One One

また、このBlendコマンドは以下のようにプロパティを使って指定することもできます。

Properties {
    _BlendSrc("Blend Src", int) = 1
    _BlendDst("Blend Dst", int) = 0
}

// (中略)

// プロパティを使ってBlendコマンドを設定
Blend [_BlendSrc] [_BlendDst]

このプロパティの値は他のプロパティと同様に、以下のようにスクリプトから設定することができます。

Material mat;
mat.SetInt("_BlendSrc", (int)UnityEngine.Rendering.BlendMode.One);
mat.SetInt("_BlendDst", (int)UnityEngine.Rendering.BlendMode.One);

これにより、複数のブレンド方法を一つのシェーダで表現できます。

なお、ZWriteコマンドやCullコマンドなども同様の手法で設定できます。

HLSLファイルに処理を切り出す

Unityではshader拡張子を持つファイルにShaderLabのコードを記述します。
これに対し、いくつかのshaderファイルに共通する処理は、hlslファイルに記述して共通化することができます。

例えば、以下のようなFoo.hlslファイルを作成したとします。

// 二重インクルード防止用の記述
#ifndef FOO_HLSL
#define FOO_HLSL

// 適当に処理を書いておく
#define COLOR_RED half4(1, 0, 0, 1)

#endif

shaderファイルで以下のようにインクルードすることで、このファイルに書いた処理を使用することができます。

Shader "Example"
{
    SubShader
    {
        Pass
        {
            HLSLPROGRAM
            #pragma vertex vert
            #pragma fragment frag

            #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
            // Foo.hlslをインクルード
            #include "Foo.hlsl"

            struct Attributes
            {
                float4 positionOS : POSITION;
            };

            struct Varyings
            {
                float4 positionHCS : SV_POSITION;
            };

            Varyings vert(Attributes IN)
            {
                Varyings OUT;
                OUT.positionHCS = TransformObjectToHClip(IN.positionOS.xyz);
                return OUT;
            }

            half4 frag(Varyings IN) : SV_Target
            {
                // Foo.hlslに定義したマクロを使用
                return COLOR_RED;
            }
            ENDHLSL
        }
    }
}

Custom Vertex Streamsを汎用的に使えるようにする

さてParticle SystemにはCustom Vertex Streamsという機能があります。
これは、Particle Systemで作成した値を頂点データとしてシェーダに渡すことのできる機能です。
アニメーションカーブで時間に応じて変化する値を渡したり、ノイズにより計算された値を渡したりできます。

Custom Vertex Streams

 

NOVAシェーダではいくつかのパラメータに対して、TEXCOORDのIndexとSwizzleを指定してCustom Vertex Streamsと連携できるようにしています。
例えば以下のようにテクスチャのOffsetに使用すれば、アーティストが思い通りにテクスチャスクロールアニメーションを実現できます。

TEXCOORDの指定

 

実装については長くなるので割愛しますが、興味がある方は Particles.hlsl を参照してください。

描画順をシェーダの外から設定する

Unityのシェーダの描画順はQueueタグを使うことで指定することができます。

Tags { "Queue"="Geometry" }

これは UnityEngine.Rendering.RenderQueue に対応しており、小さい値を持つものから順に描画されます。

public enum RenderQueue
{
    Background = 1000,
    Geometry = 2000,
    AlphaTest = 2450,
    GeometryLast = 2500,
    Transparent = 3000,
    Overlay = 4000,
}

さてこの値は上述のようにシェーダからも指定できますが、以下のようにスクリプトから設定することもできます。

Material mat;
mat.renderQueue = (int)UnityEngine.Rendering.RenderQueue.Transparent;

実際に設定するのはint型なので、描画順を細かく制御したければ以下のようにオフセットをかけることも可能です。

Material mat;
int offset = 1;
mat.renderQueue = (int)UnityEngine.Rendering.RenderQueue.Transparent;
mat.renderQueue += offset;

NOVAシェーダでは、Render TypeとRender Priorityというプロパティを使って描画順とオフセットを指定できるようにしています。

Render TypeとRender Priority

最後に

最後までお読みいただきありがとうございます。
NOVAシェーダは今後もLit版の追加など、大きなアップデートを予定しています。
ご意見やご要望などありましたお気軽に IssuePull Request を投げていただければと思います。

また、コアテクでは技術横断組織に興味がある方を絶賛募集中です。
https://hrmos.co/pages/cyberagent-group/jobs/0000865

とりあえずお話聞いてみたい方、情報交換したい方はお気軽に @harumak_11 までDMください。