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向けアプリ作った話とか です。

作って学ぶECS

はじめに

こちらの記事は先日行われた"第四十八回Unityもくもく会"にて発表したものの、詳細解説になります。

ECSって何だろう

  • Entity Component Systemの略
  • Unityに新しく追加される情報処理形態の一つ
  • ECS自体は昔からあるゲーム内オブジェクト処理記述方法の一つ[^1]

GameObject+MonoBehaviourの頃と何が違うのか

実際の仕組みを知りたいな

  • ソースを読む
    • パッケージマネージャからEntitiesを追加
    • ${ProjectFolder}/Library/PackageCache/com.unity.entities@(バージョン)/Unity.Entities 以下にそのままソースが入っています
  • 読んでみてだいたい分かったこと
    • ECSはJobSystem+PlayerLoopSystemの上に構築されている
    • ネイティブプラグインはなし
    • Unity本体の隠し機能などは利用されていない
    • unsafe祭り
    • メモリ確保はNative系

だいたい分かったことの詳細

  • JobSystem+PlayerLoopSystemの上に構築されている
    • 登録されたEntityやComponentに対する処理
      • PlayerLoopSystemでUpdate()の辺りに差し込まれ、毎フレーム自動で実行される
    • 複数のEntityに対する処理
      • JobSystemでUpdate
  • ネイティプラグインはなし
    • パッケージ内は
    • 依存パッケージまでは追わず
  • Unity本体の隠し機能などは利用されていない
    • 見た感じ
  • unsafe祭り
  • メモリ確保はNative系
    • 上記二点のお陰で、非常に読みにくいソースになっている

だいたい分かったことから思いついたこと

  • 自分で実装できるんじゃない?

してみました

https://github.com/fum1h1ro/SimpleECS

Unity2018.3.0b5で作業しましたが、特殊なものはNativeCollectionくらいしか使ってないので、2018.1辺りでも動くはずです。

Screen Shot 2018-10-19 at 12.07.59.png

サンプルとして、100x100=10,000個のCubeをサインカーブで動かしています。

また同等のものを、10,000個のGameObject+MonoBehaviourで実装しており、ボタンを押すことで切り替えることが出来るようになってます。

簡単な解説

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
35
36
37
38
39
40
41
42
43
44
45
	void Awake() {
_world = new World();
var archeType = _world.ArcheTypeManager.GetOrCreateArcheType(typeof(PositionData), typeof(RotationData));
var xOrigin = -(Width * 0.5f);
var yOrigin = -(Height * 0.5f);
for (int y = 0; y < Height; ++y) {
for (int x = 0; x < Width; ++x) {
var entity = _world.EntityManager.Create(archeType);
_world.EntityManager.SetComponentData<PositionData>(entity, new PositionData(){ pos = new Vector3(xOrigin+x, 0, yOrigin+y) });
_world.EntityManager.SetComponentData<RotationData>(entity, new RotationData(){ rot = Quaternion.identity });
}
}
_matrices = new Matrix4x4[ObjectCount];
_moveSystem = new MoveSystem(_world.ArcheTypeManager.GetOrCreateArcheType(typeof(PositionData)));
_makeMatrixSystem = new MakeMatrixSystem(archeType);
_makeMatrixSystem.Matrices = _matrices;
_makeMatrixSystem.MatricesSegments = _matricesSegments;

int count = ObjectCount;
while (count > 0) {
var len = Mathf.Min(count, 1023);
var segment = new Matrix4x4[len];
_matricesSegments.Add(segment);
count -= len;
}
}
void OnDestroy() {
_world.Dispose();
}
void Update() {
_moveSystem.Time = Time.realtimeSinceStartup;
_world.Dispatch(_moveSystem);
_world.Dispatch(_makeMatrixSystem);
#if true
foreach (var mtxs in _matricesSegments) {
Graphics.DrawMeshInstanced(_mesh, 0, _material, mtxs, mtxs.Length, null, ShadowCastingMode.Off, false);
}
#else
foreach (var mtxs in _matricesSegments) {
foreach (var mtx in mtxs) {
Graphics.DrawMesh(_mesh, mtx, _material, 0, null, 0, null, ShadowCastingMode.Off, false);
}
}
#endif
}

コメントが皆無で申し訳ないのですが、Awake()にて各種の準備を行って、Update()でEntityの更新を行うようになっています。

Worldクラスは本家と同じで、Entityを束ねる役目を果たしています。クラス名は本家に近いものになっていますが、自動呼び出しなどがないので、自分で更新を呼んでやる必要があります。

MoveSystemで位置情報を更新し、MakeMatrixSystemMatrix4x4を作っています。

描画部分が変な感じのループになっているのは、Graphics.DrawMeshInstanced()が、1023個までしか受け付けてくれないため、10,000個を1023ずつ分割して描画しているためです。[^2]

実装

ソースを見てもらうとわかると思いますが、800行程度の1ファイルに収まっています。

(詳しい説明を書く予定だった)

性能

Screen Shot 2018-10-19 at 18.19.26.png

Screen Shot 2018-10-19 at 18.19.46.png

ざっくりですが、エディタ上で見ると、10,000個のMonoBehaviourよりは早いようです。しかし、これは皆さんご存じの通り、Update()を10000回呼ぶ問題です。

ちなみに、エディタ上でNativeArrayにアクセスする際にはチェックが範囲チェックが入ります。ビルドするとその辺が消えるらしいので、もっと早くなります。

また、ECSの強みはSOAによるキャッシュミスの削減ですので、呼び出し時間も大切ですが、キャッシュミスの削減が実現されているかどうかをチェックする必要があります。

Screen Shot 2018-10-19 at 18.33.12.png

ビルドし、Instruments.appで測ってみます。上記の設定で測れているのかは確信がないのですが、L2キャッシュのリクエストのミスなのであってる?知ってる人がいたら教えてください。

SimpleECSの場合。
Screen Shot 2018-10-19 at 18.36.56.png

MonoBehaviourの場合。
Screen Shot 2018-10-19 at 18.39.31.png

type min max
SimpleECS 21 62
MonoBehaviour 57 89

び、微妙。

微妙ついでにL1D.REPLACEMENTもカウントしてみます。

SimpleECS
Screen Shot 2018-10-19 at 18.44.16.png

MonoBehaviour
Screen Shot 2018-10-19 at 18.44.51.png

type min max
SimpleECS 12 30
MonoBehaviour 37 57

数値は減ってるな。うん。

まとめ

  • ここまで一切触れてきてませんが、JobSystemは使っていません。なので、対応すればもっと早くなるはず。対応も難しくないはず
  • 性能面よりも、コンポーネントを付けたりする際にどういうメモリレイアウトになってるのかを知りたかった
  • そしてそれは果たされたし、自分で同じように実装してみて面倒くささがわかった
  • SharedComponentとか本家にある便利機能は未実装
  • unsafeなコードを書いていると、C++でも書いているんじゃないかという気分になってくる

[^1]: 最古は1998らしい https://en.wikipedia.org/wiki/Entity–component–system
[^3]: 全然関係ないですけど、公式的には"Entity Component System(通称ECS)"って書く決まりでもあるんですかね
[^2]: こういう場合は Graphics.DrawMeshInstancedIndirect()すると良いっぽいですが、そこまでは気力がなかった