游戏中的界面往往会添加一些动效来丰富玩家体验,前段时间我使用后期做了一个界面过渡的小功能,原理并不复杂,不过在刚接收需求的时候思路上也走了一些歪路,先看最终效果:
这个效果其实挺常见的,就是一个遮罩动画,相信大家分分钟就能把shader写出来,然而相我写完之后准备用到项目的时候,我发现了一个问题,我们的项目是使用NGUI做的(UGUI也有这个问题),游戏中的界面是由很多图片拼起来的,并不是一张图,而这个效果只能用于一张图片,此时我第一个想到的就是NGUI中UIPanel的SoftClip功能,众所周知,SoftClip可以在UIPanel中显示一个指定矩形范围的内容,那是否可以把这个矩形换成上面的效果呢?于是我把NGUI的SoftClip的相关的源码看了一遍,结果人家是用坐标来进行判断是否在区域内,如果在就显示,不在就把透明度制成零,难怪只支持矩形,因为好算,可是需求的效果却很复杂,因为过渡的边缘并非是线性的,当然也能用sin函数去模拟,不过这样做调节成本太大,而且生硬,所以我把这个方案PASS了。
于是我换了一个思路去思考这个问题。
1.不规则的边缘最好使用控制图去实现,因为让美术做图比写算法容易多了,而且美术可以通过修改控制图对最终效果进行微调
2.效果只能针对一张图,可是UI界面却是由多个UISprite组成的,那就把有界面时的画面和无界面的画面取出来做过渡效果,这样一来问题就被简化成在两张图中做过渡,而过渡依据就是1中所说的控制图。
下面是美术按我的需求提供的控制图
其实就是一个256X256的小图,黑色部分用于显示有界面图的颜色,白色部分用于显示没有界面图的颜色,而控制图自身只要动他的UV坐标,整个图就会从全白变到全黑,中间的过渡区由美术控制,灰度用于控制颜色权重比,所以就会有一些透明效果,shader比较简单如下:
Shader "Custom/MaskAnim"
{
Properties
{
_MainTex ("MainTex", 2D) = "white" {} //当前画面(没有播放该动画效果的画面)
_FullTex ("FullTex", 2D) = "white" {} //完整画面
_MaskTex ("MaskTex", 2D) = "white" {}
}
SubShader
{
CGINCLUDE
#include "UnityCG.cginc"
sampler2D _MaskTex;
float4 _MaskTex_ST;
sampler2D _MainTex;
sampler2D _FullTex;
struct a2v
{
float4 vertex : POSITION;
float2 uv : TEXCOORD0;
};
struct v2f
{
float2 uv : TEXCOORD0;
float2 uv2 : TEXCOORD1;
float4 vertex : SV_POSITION;
};
//----------------------------第一次UV动画-----------------------------------
v2f vert1(a2v v)
{
v2f o;
o.vertex = UnityObjectToClipPos(v.vertex);
o.uv = TRANSFORM_TEX(v.uv, _MaskTex);
o.uv2 = v.uv;
return o;
}
fixed4 frag1(v2f i) : SV_Target
{
fixed4 col = tex2D(_MaskTex, i.uv);
return col;
}
//---------------------------第二次颜色融合-----------------------------------
v2f vert2(a2v v)
{
v2f o;
o.vertex = UnityObjectToClipPos(v.vertex);
o.uv = TRANSFORM_TEX(v.uv, _MaskTex);
o.uv2 = v.uv;
return o;
}
fixed4 frag2(v2f i) : SV_Target
{
fixed4 mask_col = tex2D(_MaskTex, i.uv);
fixed4 main_col = tex2D(_MainTex, i.uv2);
fixed4 full_col = tex2D(_FullTex, i.uv2);
fixed weight = dot(mask_col.rgb, fixed3(1, 1, 1)) * 0.33333;
//fixed4 col = lerp(clip_col,main_col,weight);
fixed4 col = lerp(full_col, main_col, weight);
return col;
}
ENDCG
Lighting Off
ZTest Always
ZWrite Off
LOD 100
Pass
{
CGPROGRAM
#pragma vertex vert1
#pragma fragment frag1
ENDCG
}
Pass
{
CGPROGRAM
#pragma vertex vert2
#pragma fragment frag2
ENDCG
}
}
}
C#的代码比较简单,就是offset从(0.5,-0.5)到(-0.5,0.5)的变化
不过这个有个小问题,就是如果从(0.5,-0.5)到(-0.5,0.5),那是整个画面的过渡,根据策划的需求这是一个通过的界面过渡效果,而界面是有大有小的,假设我们配置的动画时长都一样,那么越小的界面变化的会越快,所以正确的处理方式应该计算出界面的左上角的UV坐标与左下角的UV坐标,然后再换算到(0.5,-0.5)到(-0.5,0.5)之间的变化值,为方便UI策划使用,我把整个功能开发成一个叫组件。以下是组件效果
黄色的线是界面的包围盒子,在添加组件的时候会自动生成,也可以通过修改组件上的“包围盒”属性进行调整,最后我们只要把组件提供的播放动画接口填到项目的UI框架中即可,这个UI策划只要把该组件挂到指定界面上,该界面在打开/关闭的时候就会播放相应的动画
最后给出C#核心代码给大家一个参考
组件
using System;
using UnityEngine;
using System.Collections.Generic;
using UnityEngine.Rendering;
using UnityEngine.Rendering.Universal;
using GameCore;
namespace PF.URP.PostProcessing
{
[ExecuteInEditMode]
[DisallowMultipleComponent]
[AddComponentMenu("PFUIAnim/UIMaskCommonAnim")]
public class UIMaskCommonAnim : MonoBehaviour
{
public class MaskMaterial
{
private int mResId;
private Material mMat;
private string mAssetPath;
public MaskMaterial(int resId,string assetPath)
{
mResId = resId;
mAssetPath = assetPath;
}
public Material material
{
get
{
if (mMat == null)
{
#if UNITY_EDITOR
mMat = (Material)UnityEditor.AssetDatabase.LoadAssetAtPath(mAssetPath, typeof(Material));
#else
mMat = (Material)ResMgr.Instance.LoadObject(mResId);
#endif
}
return mMat;
}
}
}
[System.Serializable]
public class Border
{
public Vector2 Center;
public Vector2 Size;
//左上角顶点
public Vector2 LT;
//左下角顶点
public Vector2 RB;
//UI矩阵
public Matrix4x4 Matrix;
public bool Init(UIMaskCommonAnim ui)
{
if (UIRoot.list.Count > 0)
{
UIRoot root = UIRoot.list[0];
if (root != null)
{
Matrix = ui.transform.localToWorldMatrix;
UIWidget[] widgets = ui.GetComponentsInChildren<UIWidget>();
foreach (UIWidget w in widgets)
{
Vector3 localPos = root.transform.worldToLocalMatrix.MultiplyPoint(w.transform.position);
//左上角
float LT_X = localPos.x + (0 - w.pivotOffset.x) * w.width;
float LT_Y = localPos.y + (1 - w.pivotOffset.y) * w.height;
//右下角
float RB_X = localPos.x + (1 - w.pivotOffset.x) * w.width;
float RB_Y = localPos.y + (0 - w.pivotOffset.y) * w.height;
//更新包围盒
LT.x = LT_X < LT.x ? LT_X : LT.x;
LT.y = LT_Y > LT.y ? LT_Y : LT.y;
RB.x = RB_X > RB.x ? RB_X : RB.x;
RB.y = RB_Y < RB.y ? RB_Y : RB.y;
}
Size.x = RB.x - LT.x;
Size.y = LT.y - RB.y;
//Debug.LogErrorFormat("计算一次! {0} {1}", RB, LT);
Center = (RB + LT) / 2;
return true;
}
}
return false;
}
/// <summary>
/// 更新顶点信息
/// </summary>
/// <returns></returns>
public void UpdateVertsByCenter()
{
LT = LT + Center;
RB = RB + Center;
}
public void UpdateVertsBySize()
{
LT.x = Center.x - Size.x / 2f;
LT.y = Center.y + Size.y / 2f;
RB.x = Center.x + Size.x / 2f;
RB.y = Center.y - Size.y / 2f;
}
}
[SerializeField]
public Border border;
public enum State
{
None,
Playing,
PlayEnd,
}
public enum PlayType
{
Forward,
Reverse,
}
public float mProcess = 0;
public float mDuration = 1f;
public AnimationCurve mCurve = new AnimationCurve(new Keyframe(), new Keyframe(1f, 1f, 2f, 2f, 0, 0));
public RenderTexture mTempRT;
public Vector4 mOffset;
public MaskMaterial mMaskMat = new MaskMaterial(603000009, "Assets/Res/Misc/UIMaskCommonAnim.mat");
private State mState = State.None;
private float mOffsetX_from = 0.5f;
private float mOffsetX_to = -0.5f;
private float mOffsetY_from = -0.5f;
private float mOffsetY_to = 0.5f;
private float mStartTime;
private int mOldUILayer;
private System.Action mOnFinish;
void Update()
{
if (mState == State.None)
return;
if (mState == State.Playing)
{
float passTime = Time.realtimeSinceStartup - mStartTime;
if (passTime > mDuration)
{
OnPlaying(mDuration);
mState = State.PlayEnd;
}
else
{
OnPlaying(passTime);
}
}
else if (mState == State.PlayEnd)
{
OnPlayEnd();
mState = State.None;
}
}
private bool OnCheck()
{
if (UIRoot.list.Count < 1)
{
Debug.LogError("UIMaskCommonAnim play error because can not found UIRoot!");
return false;
}
if (PFPostProcessingMgr.Instance.GetPostProcessing<UIMaskCommonAnim>() != null)
{
Debug.LogError("UIMaskCommonAnim is playing can not play angin!");
return false;
}
return true;
}
private void OnPlayBgin(PlayType playType)
{
UIRoot root = UIRoot.list[0];
float screenHalfWidth = root.manualWidth / 2f;
float screenHalfHeight = root.manualHeight / 2f;
//左上角uv最大值为 0.5,-0.5
float uvLT_X = 0.5f * (Math.Abs(border.LT.x) / screenHalfWidth);
float uvLT_Y = -0.5f * (Math.Abs(border.LT.y) / screenHalfHeight);
//左下角uv最大值为 -0.5,0.5
float uvRB_X = -0.5f * (Math.Abs(border.RB.x) / screenHalfWidth);
float uvRB_Y = 0.5f * (Math.Abs(border.RB.y) / screenHalfHeight);
if (playType == PlayType.Forward)
{
mOffset = new Vector4(1f, 1f, uvLT_X, uvLT_Y);
mOffsetX_from = uvLT_X;
mOffsetY_from = uvLT_Y;
mOffsetX_to = uvRB_X;
mOffsetY_to = uvRB_Y;
}
else if (playType == PlayType.Reverse)
{
mOffset = new Vector4(1f, 1f, uvRB_X, uvRB_Y);
mOffsetX_from = uvRB_X;
mOffsetY_from = uvRB_Y;
mOffsetX_to = uvLT_X;
mOffsetY_to = uvLT_Y;
}
SetCamera();
mOldUILayer = gameObject.layer;
SetLayer(LayerMask.NameToLayer("Temp3"));
PFPostProcessingMgr.Instance.AddPostProcessing(this);
mStartTime = Time.realtimeSinceStartup;
mState = State.Playing;
}
private void OnPlaying(float passTime)
{
mProcess = passTime / mDuration;
float process = mCurve.Evaluate(mProcess);
process = Mathf.Clamp(process, 0, 1f);
mOffset.z = Mathf.Lerp(mOffsetX_from, mOffsetX_to, process);
mOffset.w = Mathf.Lerp(mOffsetY_from, mOffsetY_to, process);
}
private void OnPlayEnd()
{
PFPostProcessingMgr.Instance.RemovePostProcessing(this);
SlaveCamera.Get().ClearRenderTarget();
SetLayer(mOldUILayer);
SlaveCamera.Release();
if (mTempRT != null)
{
RenderTexture.ReleaseTemporary(mTempRT);
mTempRT = null;
}
if (mOnFinish != null)
mOnFinish();
}
private void SetLayer(int layer)
{
transform.gameObject.layer = layer;
transform.SetChildLayer(layer);
}
private void SetCamera()
{
SlaveCamera.Get().ToUICamera();
SlaveCamera.Get().RenderType = CameraRenderType.Base;
//从相机取的没有该界面的整个画面
SlaveCamera.Get().SetCullingMask("UI", "Temp3");
mTempRT = RenderTexture.GetTemporary(Screen.width, Screen.height, 0, RenderTextureFormat.ARGB32);
SlaveCamera.Get().SetRenderTarget(mTempRT);
}
private void OnDestroy()
{
OnPlayEnd();
}
#if UNITY_EDITOR
void OnDrawGizmos()
{
Gizmos.matrix = border.Matrix;
Gizmos.color = Color.yellow;
Gizmos.DrawWireCube(border.Center, border.Size);
Gizmos.color = Color.clear;
}
#endif
//------------------------------------------对外接口---------------------------------------------------
public Material Material
{
get
{
return mMaskMat.material;
}
}
private bool Play(PlayType playType)
{
var ret = OnCheck();
if (ret)
OnPlayBgin(playType);
return ret;
}
public bool PlayForward(Action callBack = null)
{
mOnFinish = callBack;
return Play(PlayType.Forward);
}
public bool PlayReverse(Action callBack = null)
{
mOnFinish = callBack;
return Play(PlayType.Reverse);
}
}
}
检视面板
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEditor;
using PF.URP.PostProcessing;
namespace GameCore
{
[CustomEditor(typeof(UIMaskCommonAnim), true)]
public class UIMaskCommonAnim_Inspector : Editor
{
private UIMaskCommonAnim mTarget;
protected SerializedObject mObject;
private bool mInit = false;
private bool mEditor = false;
private string KEY_INIT = "KEY_INIT_{0}";
private string KEY_EDIT_BORDER = "KEY_EDIT_BORDER_{0}";
public void OnEnable()
{
mTarget = target as UIMaskCommonAnim;
if (mObject == null)
mObject = new SerializedObject(target);
KEY_INIT = string.Format(KEY_INIT, mTarget.GetHashCode());
KEY_EDIT_BORDER = string.Format(KEY_EDIT_BORDER, mTarget.GetHashCode());
mInit = EditorPrefs.GetBool(KEY_INIT);
mEditor = EditorPrefs.GetBool(KEY_EDIT_BORDER);
if (!mInit)
{
mInit = mTarget.border.Init(mTarget);
EditorPrefs.SetBool(KEY_INIT, mInit);
}
}
public override void OnInspectorGUI()
{
if (!mInit)
{
EditorGUILayout.BeginVertical(EditorStyles.helpBox);
EditorGUILayout.LabelField("未检测到UIRoot,请将UIMaskCommonAnim组件所属UI,放置到NGUI UIRoot下面!");
EditorGUILayout.EndVertical();
return;
}
mObject.Update();
EditorGUI.BeginDisabledGroup(true);
EditorGUILayout.ObjectField("Script", mTarget, typeof(UIMaskCommonAnim), false);
EditorGUI.EndDisabledGroup();
EditorGUILayout.BeginVertical(GUI.skin.box);
SerializedProperty duration = mObject.FindProperty("mDuration");
EditorGUILayout.PropertyField(duration, new GUIContent("动画持续时间"));
SerializedProperty curve = mObject.FindProperty("mCurve");
EditorGUILayout.PropertyField(curve, new GUIContent("速度变化曲线"));
EditorGUI.BeginDisabledGroup(true);
EditorGUILayout.ObjectField("材质球", mTarget.Material, typeof(Material), false);
EditorGUI.EndDisabledGroup();
EditorGUILayout.EndVertical();
EditorGUILayout.BeginHorizontal();
EditorGUILayout.LabelField("包围盒");
if (mEditor)
{
if (GUILayout.Button("保存", GUILayout.Width(50)))
{
mEditor = false;
EditorPrefs.SetBool(KEY_EDIT_BORDER, mEditor);
}
}
else
{
if (GUILayout.Button("编辑", GUILayout.Width(50)))
{
mEditor = true;
EditorPrefs.SetBool(KEY_EDIT_BORDER, mEditor);
}
}
EditorGUILayout.EndHorizontal();
mObject.ApplyModifiedProperties();
//包围盒属性
EditorGUILayout.BeginVertical(GUI.skin.box);
EditorGUI.BeginDisabledGroup(!mEditor);
mTarget.border.Center = EditorGUILayout.Vector2Field(" 中心", mTarget.border.Center);
mTarget.border.UpdateVertsByCenter();
mTarget.border.Size = EditorGUILayout.Vector2Field(" 范围", mTarget.border.Size);
mTarget.border.UpdateVertsBySize();
EditorGUI.EndDisabledGroup();
EditorGUILayout.EndVertical();
if (GUI.changed)
{
EditorUtility.SetDirty(mTarget);
}
}
}
}
我们项目使用URP渲染框架,以下为URP相关代码(Feature与Pass)
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Rendering;
using UnityEngine.Rendering.Universal;
using GameCore;
namespace PF.URP.PostProcessing
{
public class UIMaskCommonAnimFeature : ScriptableRendererFeature
{
public RenderPassEvent mEvent = RenderPassEvent.AfterRenderingPostProcessing;
private UIMaskCommonAnimPass mPass;
private UIMaskCommonAnim mMaskAnim;
private RenderTargetIdentifier mBaseCameraColorTarget;
public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData)
{
mMaskAnim = PFPostProcessingMgr.Instance.GetPostProcessing<UIMaskCommonAnim>();
if (mMaskAnim == null)//控制开关
return;
if (renderingData.cameraData.camera != UICamera.mainCamera)
return;
var cameraColorTarget = renderer.cameraColorTarget;
//设置当前需要后期的画面
mPass.Setup(cameraColorTarget, mMaskAnim);
//添加到渲染列表
renderer.EnqueuePass(mPass);
}
public override void Create()
{
mPass = new UIMaskCommonAnimPass(mEvent);
}
}
}
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Rendering;
using UnityEngine.Rendering.Universal;
using GameCore;
namespace PF.URP.PostProcessing
{
public class UIMaskCommonAnimPass : ScriptableRenderPass
{
private const string mCommandBufferName = "CommandBuffer_UIMaskCommonAnim";
private const string mTempTexName = "UIMaskCommonAnim Temp Texture";
private RenderTargetHandle mTempTex_Handle;
private UIMaskCommonAnim mMaskAnim;
private FilterMode mFilterMode = FilterMode.Bilinear;
private RenderTargetIdentifier mSourceRT_Id;
private int mShaderId_FullTex = Shader.PropertyToID("_FullTex");
private int mShaderId_MaskTex_ST = Shader.PropertyToID("_MaskTex_ST");
public UIMaskCommonAnimPass(RenderPassEvent @event)
{
this.renderPassEvent = @event;
mTempTex_Handle.Init(mTempTexName);
}
public void Setup(RenderTargetIdentifier sourceRT, UIMaskCommonAnim maskAnim)
{
mSourceRT_Id = sourceRT;
mMaskAnim = maskAnim;
}
private int maskTexId = Shader.PropertyToID("_MaskTex");
public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData)
{
var cmd = CommandBufferPool.Get(mCommandBufferName);
RenderImage(cmd, ref renderingData);
context.ExecuteCommandBuffer(cmd);
CommandBufferPool.Release(cmd);
}
private void RenderImage(CommandBuffer cmd, ref RenderingData renderingData)
{
mMaskAnim.Material.SetTexture(mShaderId_FullTex, mMaskAnim.mTempRT);
mMaskAnim.Material.SetVector(mShaderId_MaskTex_ST, mMaskAnim.mOffset);
RenderTextureDescriptor opaqueDesc = renderingData.cameraData.cameraTargetDescriptor;
//获取临时RT
cmd.GetTemporaryRT(mTempTex_Handle.id, opaqueDesc, mFilterMode);
//将当前相机的RT经过处理后,存入临时RT
Blit(cmd, mSourceRT_Id, mTempTex_Handle.Identifier(), mMaskAnim.Material, -1);
//将处理后的RT赋值给相机RT
Blit(cmd, mTempTex_Handle.Identifier(), mSourceRT_Id);
}
public override void FrameCleanup(CommandBuffer cmd)
{
//释放临时RT
cmd.ReleaseTemporaryRT(mTempTex_Handle.id);
}
}
}
文章评论