スマホアプリやゲームを作っていると、画面遷移時の演出に悩むことがあります。
単純なフェードイン・フェードアウトも悪くありませんが、もう少し印象に残る演出を入れることで、アプリ全体のクオリティを大きく向上させることができます。
今回は「円が広がるように画面が切り替わる演出」「黒い画面を経由しないフェード」を紹介します。
2Dアクションゲームと、特に相性が良いと考えています。
完成イメージ
画面遷移時に以下のような流れで演出を行います。
- 遷移直前の画面を画像として保存
- 次のシーンを読み込む
- 保存した画像を画面全体に表示
- プレイヤーのスタート地点から円形に画像を消していく
- 円が広がるにつれて新しいシーンが見えてくる
これにより、
- 前の画面から繋がっている感覚がある
- 単純なフェードより印象に残る
- プレイヤーの出現位置を自然に強調できる
というメリットがあります。
円形に画像を消していくのではなく、透明度を全体で下げていく事で、「黒い画面を経由しないフェード」も実現可能です。
なぜ綺麗に見えるのか
通常の演出付きシーン切り替えでは、
前の画面
↓
真っ黒(or Loading画面)
↓
次の画面
となります。
一方、この方式では、
前の画面
↓
前の画面の画像を保持
↓
遷移後のキャラの初期地点から円形に消える
↓
次の画面が見える
という流れになります。
ユーザー視点では、
「前の場所から新しい場所へ移動した」
という感覚を得やすくなります。
特にアクションゲームや探索ゲームとの相性が良い演出です。
この記事の趣旨からは逸れますが、「Loading画面に遷移する」「Loading画面から次の画面に遷移する」際にこの手法を活用する事で、Loading画面の実装難易度が格段に下がる可能性もあります。(遷移用のメソッド自体が演出を行う事で、Loading画面側が演出を考えなくて済むためです)
この実装のメリット
この実装のメリットは、フェードが綺麗に見える事よりも、2つのシーンを同時に読み込む必要がない点にあります。
前後の画面を同時に読み込む方式ではメモリ使用量増加の懸念がありますが、本手法ではシーン遷移を完了してから次の画面を表示するため、メモリ消費を抑えることができます。
実装の概要
実装自体はそれほど難しくありません。
① 遷移前にスクリーンショットを保存
シーン遷移前に現在画面を RenderTexture や Texture2D として保存します。
② 次のシーンを読み込む
通常通りシーンをロードします。
③ 保存画像を全画面表示
Canvas や SpriteRenderer で保存画像を表示します。
④ シェーダーで円形マスク
プレイヤーのスタート地点を中心として、
半径 0
↓
半径 50
↓
半径 100
↓
半径 200
↓
画面全体
という形でマスクを広げます。
shader の実装例
下記の内容は AI を使わずに実装しているため、恐らく AI で作った方がクオリティが高いと思います。
Shader "Hidden/SephirothWipeSceneSwitching"
{
Properties{
_MainTex("Texture", 2D) = "white" {}
_CircleValue("Circle Value", Float) = 0
_FirstX("FirstX", Float) = 0
_FirstY("FirstY", Float) = 0
}
SubShader{
Tags { "RenderType" = "Transparent" }
Blend SrcAlpha OneMinusSrcAlpha
Pass {
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
struct appdata {
float4 vertex : POSITION;
float2 uv : TEXCOORD0;
};
struct v2f {
float2 uv : TEXCOORD0;
float4 vertex : SV_POSITION;
};
sampler2D _MainTex;
float4 _MainTex_ST;
float _CircleValue;
float _FirstX;
float _FirstY;
v2f vert(appdata v) {
v2f o;
o.vertex = UnityObjectToClipPos(v.vertex);
o.uv = TRANSFORM_TEX(v.uv, _MainTex);
return o;
}
float4 frag(v2f i) : SV_Target{
float2 f_st = float2(i.uv.x - _FirstX, i.uv.y - _FirstY);
f_st.x /= saturate(_ScreenParams.y / _ScreenParams.x);
f_st.y /= saturate(_ScreenParams.x / _ScreenParams.y);
float pow = 0.1f + length(f_st) - _CircleValue;
//clip(pow); //If you can expect faster speed by using clip, please remove the comment out.
//pow = step(0, pow); //If you want to remove the soft mask, please remove the comment out.
pow = saturate(5 * pow);
float4 result = tex2D(_MainTex, i.uv);
result.w *= pow;
//result.w *= pow; //If you want beauty in the fade, please remove the comment out.(Multiply pow twice in total)
//result = float4(pow, pow, pow, 1) * result; //If you want to make the border darker, please remove the comment out.
return result;
}
ENDCG
}
}
}
using UnityEngine;
namespace SephirothTools.Core
{
public class SephirothWipeSceneSwitchingCore : MonoBehaviour, ISephirothDataFade, ISephirothDataWait
{
private float progress = 0;
private int frameCount = 0;
private float myDistance;
[SerializeField]
private UnityEngine.UI.Image SephirothWipeImage;
void Awake()
{
SephirothWipeImage.material.SetTexture("_MainTex", ISephirothDataFade.data.PreTexture);
SephirothWipeImage.material.SetFloat("_CircleValue", progress);
SephirothWipeImage.material.SetFloat("_FirstX", ISephirothDataFade.data.TargetPoint.x);
SephirothWipeImage.material.SetFloat("_FirstY", ISephirothDataFade.data.TargetPoint.y);
SetMyDistance();
}
void Start()
{
ISephirothDataWait.data.IsWaitTiming = true;
}
void SetMyDistance()
{
float posX = ISephirothDataFade.data.TargetPoint.x;
float posY = ISephirothDataFade.data.TargetPoint.y;
Vector2 thinkingVector = new();
if(posX < 0.5f)
{
if (posY < 0.5f)
{
thinkingVector.x = 1f - posX;
thinkingVector.y = 1f - posY;
}
else
{
thinkingVector.x = 1f - posX;
thinkingVector.y = posY;
}
}
else
{
if (posY < 0.5f)
{
thinkingVector.x = posX;
thinkingVector.y = 1f - posY;
}
else
{
thinkingVector.x = posX;
thinkingVector.y = posY;
}
}
float w = ISephirothDataFade.data.PreTexture.width;
float h = ISephirothDataFade.data.PreTexture.height;
if (w < h)
{
thinkingVector.x *= h / w;
}
else
{
thinkingVector.y *= w / h;
}
myDistance = thinkingVector.magnitude + 0.3f;
}
void FixedUpdate()
{
progress += (frameCount + 240) * 0.00013f * ISephirothDataFade.data.Speed;
if (progress < myDistance)
{
SephirothWipeImage.material.SetFloat("_CircleValue", progress);
}
else
{
ISephirothDataFade.MyInit();
ISephirothDataWait.MyInit();
UnityEngine.SceneManagement.SceneManager.UnloadSceneAsync("SephirothWipeSceneSwitchingScene");
}
frameCount++;
}
}
}
ISephirothDataFade などの implement は、implement したクラスでのみ使える共通変数の使用の権限を得るために行っています。
クラス図を自動出力した際に、仕様書と異なる interface が implement されている事が分かれば、このクラスの実装は想定と異なる実装が成されている可能性がある。という事を知れる可能性がある仕組みとして採用しています。
ISephirothDataFade.data.PreTexture にアクセスする場合、ISephirothDataFade を implement する必要があるため、ISephirothDataFade が含まれるファイルを検索するだけで、使用しているのがバレるのです。
⑤ アニメーション終了後に画像を削除
保存画像を削除して演出完了です。
Unityアセットとして公開しています
私はこの仕組みを Unity の無料アセットとして公開しています。
このアセットのように、この手法によるシーン遷移を実現する public static メソッドを作ってしまうのが賢いと思います。
そうする事で、メソッドを実行するだけで遷移できます。
もちろん自作可能な範囲の機能ですが、
- シーン切り替え処理
- スクリーンショット保存
- 円形マスク演出
- 各種調整機能
などをまとめて実装しているため、すぐ試したい方は利用してみてください。
まとめ
画面遷移はユーザーが必ず目にする部分です。
どれだけゲーム内容が良くても、遷移が味気ないと全体の印象も弱くなってしまいます。
今回紹介した「前画面を保持して円形に消していく演出」は、
- 実装難易度が比較的低い
- 見栄えが良い
- プレイヤーの位置を自然に強調できる
というメリットがあります。
画面遷移のクオリティに悩んでいる方は、ぜひ一度この仕組みを試してみてください。