12
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

Unreal Engine 4 (UE4) その2Advent Calendar 2017

Day 10

ぷちコン与太話-マテリアルでゲーム作った話-

Last updated at Posted at 2017-12-09

Unreal Engine 4 (UE4) その2 Advent Calendar 2017の12月10日の記事です。

第8回UE4ぷちコンに応募しました。

割と苦行かつ、他で役に立たない話になります。
ティーブレイクや酒の肴等にご活用くださいませ。

やったこと

  • BPを限界まで減らしてゲームを作る
    • LevelBPのみ。33ノードほど。
  • Actorを作らない
    • PlayerControllerだけはDestroyし切れなかった。のでゲーム中のOutlinerのActor数は「1」
      • 2017-12-02_19h20_48.png
  • 処理を全部Materialで書こう
    • プレイヤーの挙動、敵の出現、行動、コリジョン判定、ダメージ処理などなど

アセットリスト

  • Material: 2
  • MaterialInstance: 1
  • MaterialParameterCollection: 2
  • MaterialFunction: 72
  • Texture: 3
  • RenderTarget: 2
  • Sound: 3
  • Level: 1

他は未使用です。

BP

LevelBPのみ。下記が全実装BP

BeginPlay

2017-12-02_19h24_35.png

  • 全てのActorに対してDestroyActorを呼ぶ
    • ただし、PlayerControllerだけはDestroyされない
    • 別にやらなくてもいい。けどActorを消し去りたかった。
  • RenderTargetを画面に表示する用のPostProcessComponent追加
    • PostProcessMaterialにスクリーンにRenderTarget下記戻すマテリアルが入っています。
  • RenderTargetクリア
  • BGM再生開始

Tick

2017-12-02_19h27_45.png

  • RenderTargetをダブルバッファにして描画
    • WriteとReadを同時に行えないため

キー入力

2017-12-02_19h28_52.png

  • 上下左右、Zキーのキー状態をMaterialParameterCollectionに書き込み
    • Zキーだけは押されている間はSEを流すように

マテリアル

基本方針

  1. TickでRenderTargetに絵と情報を書き込む
    • Material'/Game/Materials/M_PP_Update.M_PP_Update'
  2. PostProcessMaterialでRenderTargetから絵の部分をスクリーンにコピー
    • Material'/Game/Materials/M_PP_Flip.M_PP_Flip'

初めはRenderTarget無しでやっていたのですが、やはりreadとwriteは同じターゲット使用できなかったのでダブルバッファを使用したRenderTargetの形になりました。
(システム情報からの読み込みと書き込みが必要になる)

  • RenderTargetの描画結果がこちら
    2017-12-02_19h31_32.png
    右端の黒い所がシステム情報。変数代わりに使用してる領域です。
    2017-12-02_19h33_18.png
    ここに、プレイヤー、弾、敵、スコアの情報等などが格納されています。
    RenderTargetを開きながらゲームプレイをしていると、目まぐるしく色が変わっているのが確認出来るかと思います。
    • TextureRenderTarget2D'/Game/Textures/RT_Screen1.RT_Screen1'

 

  • 絵の表示やシステム情報ピクセルの更新など、全ての処理を最後のOutputに繋いでいきます。
    • マテリアル処理が一つの為、全ての計算・出力を一つに収束する必要があります。
    • 全てをIf文で繋いでいきます。
    • XY(100,100)の時、
      • 描画エリア?→背景色取得→敵の描画範囲?→弾の描画範囲?→プレイヤーの描画範囲?...→MaterialOutput
      • システム情報エリア?→プレイヤー情報ピクセル?→敵情報ピクセル?...→MaterailOutput

表示に関して

絵の描画の時。通常のシーンだと配置したら勝手に描画してくれますが、こちらだと全て自分で矩形チェックをしつつ色を決定しなければいけません。

  • 今描画しようとしているピクセルXY座標から、下記のようなフローを取ります。
    1. 背景色を取る
    2. 敵の矩形に入っていれば敵の色を取る
    3. 弾の矩形に入っていれば弾の色を取る
    4. プレイヤーの矩形に入っていれば弾の色を取る
    5. UIの矩形に入っていればUIの色を取る
  • これらを1ピクセル毎に全て計算してOutputに繋ぎます。ifを多段に繋いでいきます。
  1. 描画全体フロー(MF_Disp)
    2017-12-02_19h56_07.png
  2. BG描画フロー(MF_DispBG)
    2017-12-02_19h56_58.png
    • 多重スクロール
  3. 敵描画フロー(MF_DispEnemy)
    2017-12-02_19h58_08.png
    • 20体分
      • 敵1体描画(MF_DispEnemyOne)
      • 2017-12-02_19h58_44.png
        • システム情報から読み込んで使用フラグ立っていたら表示
        • 下部はボスだけアニメーションするので特殊処理
  4. 敵の弾描画フロー(MF_DispEnemyShot)
    2017-12-04_22h35_31.png
    • 20発分
      • 弾1個描画(MF_DispEnemyShotOne)
      • 2017-12-04_22h39_53.png
        • システム情報から読み込んで使用フラグ立っていたら表示
  5. プレイヤーの弾描画フロー(MF_DispPlayerShot)
    2017-12-04_23h10_58.png
    • 10発分
      • 弾1個分(MF_DispPlayerShotOne)
      • 2017-12-04_23h12_43.png
        • システム情報から読み込んで使用フラグ立っていたら表示
  6. プレイヤー描画フロー(MF_DispPlayer)
    2017-12-04_23h14_43.png
    • 入力(MaterialParameterCollection)を見てUV変えて移動アニメーション
  7. UI描画フロー(MF_DispUI)
    2017-12-04_23h26_41.png
    • システム情報から数値取って1桁ずつ数字テクスチャから拾ってきて表示
  8. リザルト描画フロー(MF_Result)
    2017-12-04_23h29_59.png
    • UIと同じく1桁ずつ表示

システムに関して

  • システム情報のピクセルをwriteする処理です。
    XY座標が同じならその値、違えば前処理の値を引っ張ってくるようになっています。
    2017-12-02_19h38_24.png
    • 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;
}

 

  • 各種システム情報を更新していきます
    2017-12-04_23h52_09.png

座標更新

システム情報から座標をreadして、移動値を計算した結果をwriteします。

プレイヤー入力で移動

MaterialParameterCollectionにあるキー情報から移動値を加算してwriteします(MF_SystemPlayerLocation)
2017-12-02_19h49_12.png

コリジョン

コリジョンは普通に矩形で判定します。下記は1個分の当たり判定。結果を0.0 or 1.0で返します。(MF_CheckCollisionOne)
2017-12-09_14h38_19.png

  • これをヒットを取るオブジェクト全てに判定していきます。
    オブジェクトが生存しているか、座標、はシステム情報ピクセルにあるのでそこから計算
    • プレイヤーの当たり判定(MF_CheckCollisionInPlayer)
      2017-12-09_14h39_46.png
      • 敵20体分(MF_CheckCollisionToEnemy)
      • 2017-12-09_14h43_14.png
        • 敵1体分(MF_CheckCollisionToEnemyOne)
        • 2017-12-09_14h45_04.png
      • こんな感じに、プレイヤーの場合は敵、敵の弾とコリジョン
      • 敵の場合は、プレイヤー、プレイヤーの弾とコリジョンを計算していきます。

敵の処理

  • 敵の枠、20体分処理します(MF_SystemEnemy)
    2017-12-09_14h52_40.png
    • 敵1体分の処理(MF_SystemEnemyOne)
      2017-12-09_14h53_54.png
      • 敵出現、移動処理、コリジョン、ダメージ計算・演出(点滅)・死亡判定、弾の発射、などを処理してシステム情報を書き込みます

出現

  • 出現管理で、出現タイミングを計算(MF_EnemySpawnTimeline)
  • 2017-12-09_15h06_29.png
    • 出現したら、敵の枠(システム情報ピクセル)で現在使われていない枠を探して敵Spawn情報ピクセルに書き込みます(MF_SystemEnemyTimeline)
    • 2017-12-09_15h04_57.png
      • 書き込まれたSpawn情報は、次のフレームで敵1体分の処理(MF_SystemEnemyOne)の際に自分のIndexと同じかチェックしてSpawnされます(MF_GetEnemyInfo)
      • 2017-12-09_15h03_35.png

 

  • 出現タイミング(MF_EnemySpawnTimeline内のEnemySpawnerカスタムノード)
    • 下記を引数に出現を決めれる
      • 敵が前回出現してからの時間
      • 敵が全滅してからの時間
      • 現在の敵の生成数
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;

移動

  • 移動計算はCustomノードで行います(MF_EnemyMovePattern)
    2017-12-09_15h21_37.png
MF_EnemyMovePattern-MovePattern
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体)だけ複製されるので可能な限り行数を減らす必要があった

スコア処理

敵に弾が当たったらスコア増える。敵を撃破したらもっと増える。

  • 敵のスコア計算(MF_GetEnemyDamageScore)
  • 2017-12-09_16h01_37.png
    • 計算されたスコアは敵のシステム情報ピクセルに書き込まれる(MF_SystemEnemyOne)
    • フレームの更新処理時に敵20体分のシステム情報からスコア数値を拾ってきて合算、スコア情報ピクセルに書き込む
      2017-12-09_16h00_43.png
MF_GetEnemyDamageScore-Custom
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行未満にしないといけないという制限があります。
DIwLRQzVoAAvrNB.jpg

全てを一つのマテリアルにしているお陰でシェーダーコードが大きくなっていきます。
しかし一番問題になるのは、「Customノードを使用したマテリアル関数」のノードを複数作った場合、そのCustomコードが別々の関数として展開されてしまいます。

例えばこちらのマテリアル関数
2017-12-09_16h09_55.png

下記のように2箇所で使用する。
2017-12-09_16h10_17.png

出来上がるHLSLコード。同じマテリアル関数を使用しているのに、Customノードが2つ作られている。
2017-12-09_16h11_48.png

つまりCustomノードを使ったマテリアル関数ノードを複数作れば作るほどHLSLコードの行数が膨れ上がります。
1行のCustomノードでも、HLSLコード化されると6行使用します。

敵の移動のCustomコード(MF_EnemyMovePattern-MovePattern)で改行コードを極力削除しているのはこの為です。

ピクセルの計算とか細かい演算が多いので、始めはCustomノードを多用していました。
最終的には、敵の出現パターン・移動等以外はCustomノードを全て通常ノードに置き換えました。

最終的なHLSLコードの行数は32252行です。

おまけ

HLSLコードはメニューバーの「ウィンドウ」→「HLSLコード」から見れます。
2017-12-09_16h19_36.png

CalcPixelMaterialInputsがマテリアルの処理です。中々楽しいので見てみてください!

4115行目から...
2017-12-09_16h21_15.png
31956行目までがこの関数です。27841行の関数です。
2017-12-09_16h21_30.png

ローカル変数は27788個まで作られました。
2017-12-09_16h21_49.png

シェーダーコンパイル時間

シェーダーコードをちょっと書き直すと大体10~30分程度掛かります。
UE4の利点であるイテレーションの速さを吹っ飛ばす

描画負荷

2017-12-04_23h16_25.png
Instruction数は10,000を超えています。
1つのマテリアルに全部の処理を繋いでいるため膨れ上がります。
描画負荷が高くて1080pどころか552x288程度でもギリギリ60fpsでした。

処理周り

  • 1つのピクセルに書き込めるのは一回
    • なので、スコアが敵AでScore+=30、敵BでScore+=30とか出来ない。
      • スコア情報ピクセルの時に、敵Aから発生したスコアは30、敵Bから発生したスコアは30...と自分から拾いに行く形
    • 敵の出現も、敵Bの枠開いてるからそこに情報書き込み、とか出来ない。
      • 敵Bの枠空いてる情報を敵出現ピクセルで保持。敵Bのピクセル処理の時に敵出現ピクセル見に行って自分の枠だった場合に生成処理する、という形。
  • いつものプログラムの書き方と違ってまどろっこしい形に。混乱した。

サウンド

敵が撃破されたタイミングやプレイヤーがダメージ受けたタイミングがBPで取得できない。
なのでBGMと弾発射SEしか組み込めていない。

改善点

絵の描画とシステム情報の計算を一緒にしているのが不味い(何とか一発書き出来ないか試してた名残)
RenderTargetへはシステム情報、最後の画面に書き戻すPostProcessMaterialで絵の描画すべき。

さいごに

是非誰か次のぷちコンで試してもっと良いの作ってみて下さい。

僕はもうやんない。普通につくる。

12
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
12
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?