Mobile でも Deferred Shading がしたいです

これは Unity 2 Advent Calendar 2015 の18日目の記事です。

17日目の記事は Kan_Kikuchi さんの 素材系のおすすめAsset38選 でした。

未検証ですが Unity2017.1.0f1 にて Metal での Deferred Rendering がサポートされたという話です。

ご挨拶

株式会社オインクゲームズ@fum1h1ro です。

弊社はアナログゲームのイメージが強いのですが、実はデジタルゲームも作っています。

Screen Shot 2015-12-11 at 19.50.06.png

幸いにして、伝説の旅団は AppStoreJP の BEST OF 2015 に選出されました。こちらもよろしくお願いします。

Mobile でも Deferred Shading がしたいです

ところで、皆さんは Mobile でも Deferred Shading がしたいと思ったことはありませんか。

Deferred Shadingとは何ぞや?ってのはこの辺の記事を参考にしてください。
http://game.watch.impress.co.jp/docs/series/3dcg/20090417_125909.html

僕はあります。

Unity のバージョンが上がるにつれ、 Mobile と Desktop/Console における機能差が減りつつある昨近ですが、未だ Deferred Shading は対応されていません。

重いとか軽いとかはどうでもいい(よくない)のですが、 Metal/OpenGL ES 3.0 では MRT が機能するのですから、対応してくれても罰は当たらない気がします。

5.3.0f4 で試すと、エディタ上では機能するのですが、実機動作時に Camera.actualRenderingPathRenderingPath.Forward になってしまいます。また、 Profiler で見ても RenderForwardOpaque() が呼び出されており、 Deferred Shading になっていないのが確認出来ます。

Screen Shot 2015-12-11 at 19.50.06.png

ちなみに、エディタ上でも、Edit->Graphics Emulation が OpenGL ES 2.0 になっているとダメです。ちなみにこの設定、エディタが憶えていてくれないので、起動時に毎回手で直すのが地味に辛いです。

Screen Shot 2015-12-11 at 19.27.10.png

5.2 の頃から検証を始め、対応状況に残念がりつつも 5.3 ならきっと対応してくれるだろうから Advent Calendar に書くほどでもないと思ったのですが、 5.3 リリース版でもダメでした。なお、5.3.0f5 とかで急に対応され、この記事が無駄になることを切に祈ります。

どの機種から可能なのか

iOS なら Metal に対応した端末、Android なら OpenGL ES 3.0 に対応した端末であれば、原理的には可能なはずです。具体的には、SystemInfo.supportedRenderTargetCount が2以上であることが条件です。

尚、今回は必ず対応機種であるということを前提にしていますが、実際に使う際は非対応端末時の処理が必要になると思います。

無理矢理実装してみる

簡単ですが、独自実装 Deferred Shading を行う仕組みについて説明します。

描画パス

流れとしては、

  1. サブカメラと G-Buffer を用意
  2. OnPreCull() で、サブカメラにメインカメラの設定をコピーしてレンダリング
  3. メインカメラの OnRenderImage() で複数バッファを統合
  4. 2に戻る

という感じになります。

独自実装 Deferred Shading クラス

まず、エディタ上でも常に動作して欲しいので、ExecuteInEditMode を付けます。また、OnRenderImage() を使いたいので、 Camera コンポーネント必須にします。

1
2
3
4
[ExecuteInEditMode]
[RequireComponent(typeof(Camera))]
public class DeferredMaster : MonoBehaviour {
const int RenderTargetCount = 4;

サブカメラと G-Buffer を用意

ExecuteInEditMode が付いている場合、OnEnable() で初期化を行います。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
void OnEnable() {
if (!isSupported) return;
initialize();
}
void initialize() {
if (_initialized) return;
this.screenWidth = Screen.width;
this.screenHeight = Screen.height;
_mainCamera = gameObject.GetComponent<Camera>();
_transform = transform;
create_materials(); // 統合用マテリアルの生成
create_camera(); // サブカメラ生成
create_rt(); // G-Buffer生成
_initialized = true;
}
void create_materials() {
_uniteMaterial = CreateMaterial("uniteMat", Shader.Find("Hidden/Oink/DeferredUnite"));
}
void create_camera() {
var obj = new GameObject("__deferred camera");
obj.transform.parent = _transform;
obj.transform.localPosition = Vector3.zero;
obj.transform.localRotation = Quaternion.identity;
obj.hideFlags = HideFlags.DontSave;
_deferredCamera = obj.AddComponent<Camera>();
_deferredCamera.enabled = false;
}
void create_rt() {
for (int i = 0; i < RenderTargetCount; ++i) {
_mrtTex[i] = CreateRenderTexture(this.screenWidth, this.screenHeight, 0, RenderTextureFormat.ARGB32);
_mrtRB[i] = _mrtTex[i].colorBuffer;
}
_mrtDepth = CreateRenderTexture(this.screenWidth, this.screenHeight, 24, RenderTextureFormat.Depth);
}

Unity 組み込みの Deferred Shading では当然 G-Buffer をシステム側で確保してくれるのですが、今回の場合は自分で用意する必要があります。その際、メインカメラにその設定をすると、 GUI やらに問題が出る場合があるので、別カメラによるレンダリングを行います。

OnPreCull() でサブカメラにメインカメラの設定をコピーしてレンダリング

ここから先、エディタでは更新があった時しか呼ばれませんが、実行中は毎フレーム呼ばれます。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void OnPreCull() {
if (_initialized) {
_deferredCamera.CopyFrom(_mainCamera); // メインカメラの設定をサブカメラにコピー
_deferredCamera.SetTargetBuffers(_mrtRB, _mrtDepth.depthBuffer); // サブカメラのレンダーターゲットを設定
_deferredCamera.Render(); // サブカメラレンダリング
// メインカメラは何も書かなくて良いので、マスクをクリア、かつ値を保存しておく
_cullingMask = _mainCamera.cullingMask;
_mainCamera.cullingMask = 0;
}
}
void OnPostRender() {
if (_initialized) {
_mainCamera.cullingMask = _cullingMask; // マスクを戻してやる
}
}

これで、描画されるモデルのマテリアルを MRT 対応シェーダにしておくと、先ほど用意した G-Buffer に値が書き込まれるようになります。

Screen Shot 2015-12-11 at 20.41.42.png

1
2
3
4
5
6
7
8
9
void OnGUI() {
if (_initialized) {
float w = Screen.width / 6;
float h = Screen.height / 6;
for (int i = 0; i < RenderTargetCount; ++i) {
if (_mrtTex[i] != null) GUI.DrawTexture(new Rect(0, i*h, w, h), _mrtTex[i]);
}
}
}

デバッグ時には、このようにテクスチャの中身を確認すると楽です。

MRT 対応シェーダ

素直に Camera.renderingPath を設定しても反映されないのですが、実はシェーダの方で MRT 対応のシェーダを書けば、RenderingPath.Forward のまま複数のバッファに出力することが可能になります。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 出力先の定義
struct Output {
fixed4 rt0 : COLOR0;
fixed4 rt1 : COLOR1;
fixed4 rt2 : COLOR2;
fixed4 rt3 : COLOR3;
};
// フラグメントシェーダ
Output frag(v2f f) {
Output o;
o.rt0 = fixed4(((normalize(f.nml) * 0.5 + fixed3(0.5, 0.5, 0.5)) * 0.95), _Roughness); // RGB:Normal A:Roughness
o.rt1 = fixed4((tex2D(_MainTex, f.uv) * _Color).xyz, _Metallic); // RGB:Diffuse A:Metallic
o.rt2 = fixed4(1.0f, 0.0f, 0.0f, 1.0f); // 未使用
o.rt3 = fixed4(0.0f, 1.0f, 1.0f, 1.0f); // 未使用
return o;
}

フラグメントシェーダの返値を、上記のように複数にしてやります。Lighting 等は少しでも負荷を軽くするためにオフにしておく方が良いでしょう。

メインカメラの OnRenderImage() で複数バッファを統合

最後に、メインカメラの OnRenderImage() で G-Buffer を統合します。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
void OnRenderImage(RenderTexture source, RenderTexture destination) {
if (!_initialized) { // 非対応端末の場合は、そのままコピーしてお終い
Graphics.Blit(source, destination);
return;
}
// :
// 長いので中略
// :
_uniteMaterial.SetVector("_CameraWS", _mainCamera.transform.position);
// G-Buffer
_uniteMaterial.SetTexture("_RT0", _mrtTex[0]);
_uniteMaterial.SetTexture("_RT1", _mrtTex[1]);
_uniteMaterial.SetTexture("_RT2", _mrtTex[2]);
_uniteMaterial.SetTexture("_RT3", _mrtTex[3]);
_uniteMaterial.SetTexture("_DepthTexture", _mrtDepth);
_uniteMaterial.SetMatrix("_worldToCameraMatrix", _deferredCamera.worldToCameraMatrix);
// ライトの設定を渡してレンダリング
_uniteMaterial.SetColor("_lightColor", new Color(1.0f, 1.0f, 1.0f, 1.0f));
_uniteMaterial.SetVector("_lightPos", _mainCamera.worldToCameraMatrix * new Vector4(0, 5, -5, 1));
Graphics.Blit(source, destination, _uniteMaterial, 0);
// ライトの数に応じて複数回レンダリングする
//_uniteMaterial.SetColor("_lightColor", new Color(0.5f, 0.5f, 0.5f, 1.0f));
//_uniteMaterial.SetVector("_lightPos", _mainCamera.worldToCameraMatrix * new Vector4(-5, 1, 5, 1));
//Graphics.Blit(source, destination, _uniteMaterial, 1);
}

Deferred Shading において、 G-Buffer の統合は即ちライティングでもあるため、ライトの数に応じてレンダリング回数を調整します。

物理ベースシェーディング

統合用のシェーダは長いので省略しますが、物理ベースシェーディングを実装するところまで頑張ってみました。

Screen Shot 2015-12-15 at 20.05.28.png

Screen Shot 2015-12-15 at 20.05.59.png

Screen Shot 2015-12-15 at 20.06.19.png

これらのスクリーンショットは、 Unity の Standard シェーダではなく、独自シェーダのレンダリング結果です。 UE4 と同じ Diffuse(Lambert) + Specular(GGX) という組み合わせです。

物理ベースシェーディングは、 Forward Shading でも出来ることなので、説明は割愛します。

Slack for iOS Upload.png.jpeg

こちらは iPhone6s でのスクリーンショットです。

問題点

これまでの方法で、 Mobile であっても強制的に Deferred Shading を行うことが出来るようになりましたが、いくつか問題があります。

  • 半透明の扱い
  • ライトの指定方法
  • 描画負荷
  • SceneView が死ぬ

半透明の扱いは今回だけの問題ではなく、 Deferred Shading にはつきもので、何とかしてやる必要がありますが、紙面の都合上割愛します。

ライトの指定方法については、独自クラスを作るよりは既存の Light から値を引っ張ってくる方が、既存の手法と親和性が高そうです。

Unity 組み込みの影は反映されません。なんか上手い方法を考えましょう。

描画負荷はどうにもならないので、うまいこと良い感じに軽いシェーダを書きましょう。

そして最後の SceneView ですが、今回の手法をそのまま使っていると、下図のようになってしまいます。

Screen Shot 2015-12-11 at 20.48.33.png

最初の G-Buffer に出力した色がそのまま出てしまいます。まあ、このままでも作業出来なくもないのですが、流石に辛いと思われます。

まだ実装していないのですが、エディタ上であれば Unity 組み込みの Deferred Shading が iOS プラットフォームでも機能することを利用して、エディタではそちらで処理し、実機時のみ独自実装 Deferred Shading にすればいいのかなと考えています。ただ、その際は組み込み Deferred Shading のシェーダ等も独自実装の方に出力結果を合わせておく必要があります。

  • エディタ上では、Standalone の PlayerSettings に依存

何故か Standalone Platform(Win/Mac/Linux) の PlayerSettings にある RenderingPath が、Deferred になっていると正常に動作しません。罠としか思えません。これで半日潰しました。

最後に

今回の変則的な手法のようなものも許容してくれるのは、 Unity のレンダリング周りがシンプルであるが故の長所だと思います。サブカメラを使う方法は、画面クリア設定等に気を遣わないと無駄な処理が発生する場合がありますが、うまく使うと色々応用が利きます。

独自実装 Deferred Shading については、問題点も多く、実用まで持っていくには時間が掛かりそう、かつ本体で対応されて水の泡という未来しか見えません('A`)

そんな結論かよ!といったところで、今回の記事は終わります。

ありがとうございました('ω`)

追記

  • 5.3.1f1 NG
  • 5.4.0b1 NG
  • 5.3.1p3 NG
  • 5.4.0b2 NG

明日は、 wotakuro さんの UnityでMoverio BT-200向けアプリ作った話とか です。