Unreal Engine 4 (UE4) その2 Advent Calendar 2017の12月10日の記事です。
第8回UE4ぷちコンに応募しました。
- 動画はコチラ
https://www.youtube.com/watch?v=QIA5XQDvehQ - プロジェクトも配布しています
http://com04.sakura.ne.jp/unreal/wiki/index.php?%C2%E88%B2%F3UE4%A4%D7%A4%C1%A5%B3%A5%F3
割と苦行かつ、他で役に立たない話になります。
ティーブレイクや酒の肴等にご活用くださいませ。
やったこと
- BPを限界まで減らしてゲームを作る
- LevelBPのみ。33ノードほど。
- Actorを作らない
- 処理を全部Materialで書こう
- プレイヤーの挙動、敵の出現、行動、コリジョン判定、ダメージ処理などなど
アセットリスト
- Material: 2
- MaterialInstance: 1
- MaterialParameterCollection: 2
- MaterialFunction: 72
- Texture: 3
- RenderTarget: 2
- Sound: 3
- Level: 1
他は未使用です。
BP
LevelBPのみ。下記が全実装BP
BeginPlay
- 全てのActorに対してDestroyActorを呼ぶ
- ただし、PlayerControllerだけはDestroyされない
- 別にやらなくてもいい。けどActorを消し去りたかった。
- RenderTargetを画面に表示する用のPostProcessComponent追加
- PostProcessMaterialにスクリーンにRenderTarget下記戻すマテリアルが入っています。
- RenderTargetクリア
- BGM再生開始
Tick
- RenderTargetをダブルバッファにして描画
- WriteとReadを同時に行えないため
キー入力
- 上下左右、Zキーのキー状態をMaterialParameterCollectionに書き込み
- Zキーだけは押されている間はSEを流すように
マテリアル
基本方針
- TickでRenderTargetに絵と情報を書き込む
- Material'/Game/Materials/M_PP_Update.M_PP_Update'
- PostProcessMaterialでRenderTargetから絵の部分をスクリーンにコピー
- Material'/Game/Materials/M_PP_Flip.M_PP_Flip'
初めはRenderTarget無しでやっていたのですが、やはりreadとwriteは同じターゲット使用できなかったのでダブルバッファを使用したRenderTargetの形になりました。
(システム情報からの読み込みと書き込みが必要になる)
- RenderTargetの描画結果がこちら
右端の黒い所がシステム情報。変数代わりに使用してる領域です。
ここに、プレイヤー、弾、敵、スコアの情報等などが格納されています。
RenderTargetを開きながらゲームプレイをしていると、目まぐるしく色が変わっているのが確認出来るかと思います。- TextureRenderTarget2D'/Game/Textures/RT_Screen1.RT_Screen1'
- 絵の表示やシステム情報ピクセルの更新など、全ての処理を最後のOutputに繋いでいきます。
- マテリアル処理が一つの為、全ての計算・出力を一つに収束する必要があります。
- 全てをIf文で繋いでいきます。
- XY(100,100)の時、
- 描画エリア?→背景色取得→敵の描画範囲?→弾の描画範囲?→プレイヤーの描画範囲?...→MaterialOutput
- システム情報エリア?→プレイヤー情報ピクセル?→敵情報ピクセル?...→MaterailOutput
表示に関して
絵の描画の時。通常のシーンだと配置したら勝手に描画してくれますが、こちらだと全て自分で矩形チェックをしつつ色を決定しなければいけません。
- 今描画しようとしているピクセルXY座標から、下記のようなフローを取ります。
- 背景色を取る
- 敵の矩形に入っていれば敵の色を取る
- 弾の矩形に入っていれば弾の色を取る
- プレイヤーの矩形に入っていれば弾の色を取る
- UIの矩形に入っていればUIの色を取る
- これらを1ピクセル毎に全て計算してOutputに繋ぎます。ifを多段に繋いでいきます。
- 描画全体フロー(MF_Disp)
- BG描画フロー(MF_DispBG)
- 多重スクロール
- 敵描画フロー(MF_DispEnemy)
- 敵の弾描画フロー(MF_DispEnemyShot)
- プレイヤーの弾描画フロー(MF_DispPlayerShot)
- プレイヤー描画フロー(MF_DispPlayer)
- 入力(MaterialParameterCollection)を見てUV変えて移動アニメーション
- UI描画フロー(MF_DispUI)
- システム情報から数値取って1桁ずつ数字テクスチャから拾ってきて表示
- リザルト描画フロー(MF_Result)
- UIと同じく1桁ずつ表示
システムに関して
- システム情報のピクセルをwriteする処理です。
XY座標が同じならその値、違えば前処理の値を引っ張ってくるようになっています。
- ifノードはBilinearで補間が掛かっても大丈夫なように、Thresholdは1.0になっています。
- これを書き出す全情報に対して繋いでいきます。XY座標のifノードがマッチした情報だけ書き出されます。
- read処理は特筆することはなく、そのままTextureSampleです。
- また、float1つに対して1つの情報にすると数が多くなるので、幾つかの情報をfloat1つに纏めたりしています。
{
int iPack = 0;
iPack |= (IsSpawn) ? 1 : 0;
iPack |= (Type & 0x7) << 1;
iPack |= (MoveType << 4);
Ret.a = iPack;
}
座標更新
システム情報から座標をreadして、移動値を計算した結果をwriteします。
プレイヤー入力で移動
MaterialParameterCollectionにあるキー情報から移動値を加算してwriteします(MF_SystemPlayerLocation)
コリジョン
コリジョンは普通に矩形で判定します。下記は1個分の当たり判定。結果を0.0 or 1.0で返します。(MF_CheckCollisionOne)
- これをヒットを取るオブジェクト全てに判定していきます。
オブジェクトが生存しているか、座標、はシステム情報ピクセルにあるのでそこから計算
敵の処理
出現
- 出現タイミング(MF_EnemySpawnTimeline内のEnemySpawnerカスタムノード)
- 下記を引数に出現を決めれる
- 敵が前回出現してからの時間
- 敵が全滅してからの時間
- 現在の敵の生成数
- 下記を引数に出現を決めれる
// SpawnFrame
// AbortFrame
int InIndex = int(Index + 0.5f);
int NowIndex = InIndex;
bool IsSpawn = false;
int Type = 0;
int MoveType = 0;
float2 Location = float2(800,400);
#define DEF_LOCATION(Loc) (Loc)
#define DEF_SPAWN(InFrame, InType, InMove, InLocation) if (SpawnFrame >= InFrame) { ++NowIndex; IsSpawn = true; Type = InType; MoveType = InMove; Location = DEF_LOCATION(InLocation); }
#define DEF_ABORT(InFrame, InType, InMove, InLocation) if (AbortFrame >= InFrame) { ++NowIndex; IsSpawn = true; Type = InType; MoveType = InMove; Location = DEF_LOCATION(InLocation); }
switch (InIndex)
{
case 0:
case 1:
case 2:
case 3:
case 4:
DEF_SPAWN(40, 0, 0, float2(2000, 150));
break;
case 5:
case 6:
case 7:
case 8:
case 9:
DEF_SPAWN(40, 0, 1, float2(2000, 930));
break;
case 10:
DEF_SPAWN(300, 1, 10, float2(2000, 200));
break;
case 11:
DEF_SPAWN(30, 1, 11, float2(2000, 880));
break;
case 12:
DEF_SPAWN(30, 1, 10, float2(2000, 200));
break;
case 13:
DEF_SPAWN(30, 1, 11, float2(2000, 880));
break;
case 14:
DEF_SPAWN(200, 3, 20, float2(2200, 600));
break;
}
#undef DEF_ABORT
#undef DEF_SPAWN
#undef DEF_LOCATION
float4 Ret;
Ret.r = NowIndex;
Ret.gb = Location;
{
int iPack = 0;
iPack |= (IsSpawn) ? 1 : 0;
iPack |= (Type & 0x7) << 1;
iPack |= (MoveType << 4);
Ret.a = iPack;
}
return Ret;
移動
int InMoveType = int(MoveType + 0.5f);int InLiveFrame = int(LiveFrame + 0.5f);int3 InRandom = int3(Random + 0.5);
float2 Speed = float2(0,0);float2 ShotVector = float2(0,0);
if (InMoveType <= 1) { /* ∞ */
if (InLiveFrame < 74) { Speed.x -= 8; } else {
const int iGoFrame = 120; const float GoFrame = iGoFrame; const float MoveRangeX = 1920 - 400; float Flag = (InMoveType == 0) ? 1 : -1;
int Time = (InLiveFrame - 45) % (iGoFrame * 2);
if (Time <= GoFrame) {
float f = Time / GoFrame; Location.x = (MoveRangeX + 200) - MoveRangeX * f; Location.y = -sin(f * 6.28318548) * 390 * Flag + 540;
} else {
float f = 2.0f - Time/GoFrame; Location.x = (MoveRangeX + 200) - MoveRangeX * f; Location.y = sin(f * 6.28318548) * 390 * Flag + 540;
}
}
} else if (InMoveType == 10) { /* 直進 */
if (InLiveFrame < 300) Speed = float2(-6, -0.4);
else {
if (((InLiveFrame - 300) % 800) < 400) Speed = float2(4, 0); else Speed = float2(-4, 0);
}
if ((InLiveFrame % 60) == 40) ShotVector = float2(-3, 5); else if ((InLiveFrame % 60) == 45) ShotVector = float2(-4, 4); else if ((InLiveFrame % 60) == 50) ShotVector = float2(-5, 3);
} else if (InMoveType == 11) { /* 直進 */
if (InLiveFrame < 300) Speed = float2(-6,-0.4);
else {
if (((InLiveFrame - 300) % 800) < 400) Speed = float2(4, 0); else Speed = float2(-4, 0);
}
if ((InLiveFrame % 60) == 41) ShotVector = float2(-3, -5); else if ((InLiveFrame % 60) == 46) ShotVector = float2(-4, -4); else if ((InLiveFrame % 60) == 51) ShotVector = float2(-5, -3);
} else if (InMoveType == 20) {
if (InLiveFrame < 120) Speed = float2(-6,0);
else {
if ((InLiveFrame % 120) == 108) ShotVector = float2(-4.5, -3.5) * max(InRandom.rg / 64, 1) ; else if ((InLiveFrame % 120) == 110) ShotVector = float2(-6, -2) * max(InRandom.rg / 64, 1); else if ((InLiveFrame % 120) == 113) ShotVector = float2(-7, 0) * max(InRandom.rg / 64, 1); else if ((InLiveFrame % 120) == 116) ShotVector = float2(-6, 2) * max(InRandom.rg / 64, 1); else if ((InLiveFrame % 120) == 119) ShotVector = float2(-4.5, 3.5) * max(InRandom.rg / 64, 1);
if (InLiveFrame < 1000) {
if ((InLiveFrame % 360) >= (360 - 5 * 3)) Speed = float2(20,0); else if ((InLiveFrame % 360) >= (360 - 5 * 5)) Speed = float2(0,0); else if ((InLiveFrame % 360) >= (360 - 5 * 8)) Speed = float2(-20,0);
} else if (InLiveFrame < 2000) {
if ((InLiveFrame % 360) >= (360 - 5 * 3)) Speed = float2(7,0); else if ((InLiveFrame % 360) >= (360 - 5 * 5)) Speed = float2(0,0); else if ((InLiveFrame % 360) >= (360 - 5 * 8)) Speed = float2(-20,0);
} else {
if ((InLiveFrame % 360) >= (360 - 5 * 3)) Speed = float2(20,0); else if ((InLiveFrame % 360) >= (360 - 5 * 5)) Speed = float2(0,0); else if ((InLiveFrame % 360) >= (360 - 5 * 8)) Speed = float2(-20,0);
}
}
}
float4 Ret;Ret.xy = Location + Speed;Ret.zw = ShotVector;
return Ret;
- なんで改行コードが死んでるの?
- 後述の"困ったこと - コードの限界"あたりを参照。
- 簡単に言うとHLSL展開されたコード行数が問題になる為、改行コードを可能な限り消した
- このCustomノード、敵の数(20体)だけ複製されるので可能な限り行数を減らす必要があった
- 後述の"困ったこと - コードの限界"あたりを参照。
スコア処理
敵に弾が当たったらスコア増える。敵を撃破したらもっと増える。
int iLife = int(Life + 0.5);int iType = int(Type + 0.5);int Score = 0;
if (iType == 0) { Score = (iLife > 0) ? 10 : 100; }
else if (iType == 1) { Score = (iLife > 0) ? 30 : 300; }
else if (iType == 2) { Score = (iLife > 0) ? 30 : 5000; }
if (iType == 3) { Score = (iLife > 0) ? 30 : 30000; }
return float(Score);
- 敵の種類でスコアを変えてる
困ったこと
コードの限界
UE4のマテリアルは、ネイティブにコンパイルされる前に一度中間コードのHLSLコードに変換されます。
その変換されるHLSLコードは、32768行未満にしないといけないという制限があります。
全てを一つのマテリアルにしているお陰でシェーダーコードが大きくなっていきます。
しかし一番問題になるのは、「Customノードを使用したマテリアル関数」のノードを複数作った場合、そのCustomコードが別々の関数として展開されてしまいます。
出来上がるHLSLコード。同じマテリアル関数を使用しているのに、Customノードが2つ作られている。
つまりCustomノードを使ったマテリアル関数ノードを複数作れば作るほどHLSLコードの行数が膨れ上がります。
1行のCustomノードでも、HLSLコード化されると6行使用します。
敵の移動のCustomコード(MF_EnemyMovePattern-MovePattern)で改行コードを極力削除しているのはこの為です。
ピクセルの計算とか細かい演算が多いので、始めはCustomノードを多用していました。
最終的には、敵の出現パターン・移動等以外はCustomノードを全て通常ノードに置き換えました。
最終的なHLSLコードの行数は32252行です。
おまけ
HLSLコードはメニューバーの「ウィンドウ」→「HLSLコード」から見れます。
CalcPixelMaterialInputsがマテリアルの処理です。中々楽しいので見てみてください!
4115行目から...
31956行目までがこの関数です。27841行の関数です。
シェーダーコンパイル時間
シェーダーコードをちょっと書き直すと大体10~30分程度掛かります。
UE4の利点であるイテレーションの速さを吹っ飛ばす
描画負荷
Instruction数は10,000を超えています。
1つのマテリアルに全部の処理を繋いでいるため膨れ上がります。
描画負荷が高くて1080pどころか552x288程度でもギリギリ60fpsでした。
処理周り
- 1つのピクセルに書き込めるのは一回
- なので、スコアが敵AでScore+=30、敵BでScore+=30とか出来ない。
- スコア情報ピクセルの時に、敵Aから発生したスコアは30、敵Bから発生したスコアは30...と自分から拾いに行く形
- 敵の出現も、敵Bの枠開いてるからそこに情報書き込み、とか出来ない。
- 敵Bの枠空いてる情報を敵出現ピクセルで保持。敵Bのピクセル処理の時に敵出現ピクセル見に行って自分の枠だった場合に生成処理する、という形。
- なので、スコアが敵AでScore+=30、敵BでScore+=30とか出来ない。
- いつものプログラムの書き方と違ってまどろっこしい形に。混乱した。
サウンド
敵が撃破されたタイミングやプレイヤーがダメージ受けたタイミングがBPで取得できない。
なのでBGMと弾発射SEしか組み込めていない。
改善点
絵の描画とシステム情報の計算を一緒にしているのが不味い(何とか一発書き出来ないか試してた名残)
RenderTargetへはシステム情報、最後の画面に書き戻すPostProcessMaterialで絵の描画すべき。
さいごに
是非誰か次のぷちコンで試してもっと良いの作ってみて下さい。
僕はもうやんない。普通につくる。