前言
- 趁着端午假期+调休,折腾了一丢丢Unity相关的东西,大都是跟着油管上的5~10min教程做,有这个瞬移斩、FFVI技能模式、粒子系统做刀光等
- 其中也有一些看了下远超认知的项目,看看巨佬是如何表演的,顺便让我认清自己是只毛都没开始长的菜鸡.jpg。
- 比如这个飞行的游戏,3个视频1.5小时多的视频几乎每秒对于我来说都是精华,从第一部分自定义网格+CG+HLSL+一堆看不懂的公式模拟地球,到后面开普勒、牛顿等仿公转、天气气流等又一堆看不懂的东西,着实感受到综合素质的差距
- 可以说,我根本想象不出我能复刻这东西的样子,不过这也能让我冷静下来继续学习便是
- 于是我决定还是尝试从一些简单的demo进行复刻,偶尔会遇到bug那就解决并记录,还是稳扎稳打比较实在
其实在学shader之前就看到瞬移斩的教程,只是那时候看到shader的部分就一脸懵逼(懂了之后就几条连线很简单)。另外关于shader(写过一篇)、粒子系统、Cinemachine等在本文不会涉及
效果预览
简约线条版(当前编写)
极致色彩版(模仿对象)
对比教程的改动
- 去掉一些后处理效果
- 修复锁定图标操作过快时旋转时会出现角度异常BUG
- 剑会往脚底下飞的BUG
- 18版本迁移到20版本,一些shader被遗弃,重新用shader graph构造
逐步编写
基础场景+脚本
关于摄像机、旧版InputSystem、移动脚本、非技能动画状态机这些就不讲了
- 初始脚本如下:
using System.Collections.Generic;
using UnityEngine;
using Cinemachine;
public class WarpController_Clone : MonoBehaviour
{
private MovementInput input;
private Animator anim;
[Header("摄像机")]
public CinemachineFreeLook cameraFreeLook;
private CinemachineImpulseSource impulse;
[Space]
[Header("敌人相关参数")]
public List<Transform> screenTargets = new List<Transform>();
public Transform target;
public float warpDuration = .5f;
[Tooltip("剑显现粒子")]
public ParticleSystem swordParticle;
[Space]
[Header("剑参数")]
public Transform sword;
public Transform swordHand;
private Vector3 swordOrigRot;
private Vector3 swordOrigPos;
private MeshRenderer swordMesh;
// Start is called before the first frame update
void Start()
{
// 隐藏鼠标指针
Cursor.visible = false;
input = GetComponent<MovementInput>();
anim = GetComponent<Animator>();
// 获取相机控制震动效果的组件
impulse = cameraFreeLook.GetComponent<CinemachineImpulseSource>();
// 保存剑的欧拉角、坐标
swordOrigRot = sword.localEulerAngles;
swordOrigPos = sword.localPosition;
// 控制剑的shader
swordMesh = sword.GetComponentInChildren<MeshRenderer>();
// 剑消失
swordMesh.enabled = false;
}
void Update()
{
anim.SetFloat("Blend", input.Speed);
}
}
- 结合没有提及的移动脚本,当前的效果是能使人物正常移动,如下GIF所示:
保存敌人对象List
- 逻辑是根据摄像机是否可见来添加/删除敌人List,代码如下:
using UnityEngine;
public class TargetScript : MonoBehaviour
{
WarpController warp;
void Start()
{
warp = FindObjectOfType<WarpController>();
}
/// <summary>
/// Unity内置回调,当物体进入摄像机视野时回调
/// </summary>
private void OnBecameVisible()
{
if (!warp.screenTargets.Contains(transform))
warp.screenTargets.Add(transform);
}
/// <summary>
/// Unity内置回调,当物体离开摄像机视野时回调
/// </summary>
private void OnBecameInvisible()
{
if(warp.screenTargets.Contains(transform))
warp.screenTargets.Remove(transform);
}
}
- 把脚本挂载到空物体上,效果如下:
增加敌人锁定标识
- 现在场景中增加Canvas,并增加两张锁定图标图片:
- 之所以把【image(1)-子对象-确认锁定】放在【image-父对象-预锁定】下,是为了让image位置发生变化时,image(1)也能随之变动(但这会导致DOTween的一个问题)
- 之后添加如下代码,将预锁定挂在敌人身上(顺便把确认锁定的资源也加了):
// 略...
public bool isLocked;
[Header("Canvas(UI控制)")]
[Tooltip("目标自动锁定图片")]
public Image aim;
[Tooltip("右键锁定图片")]
public Image lockAim;
[Tooltip("锁定图标在屏幕空间的偏移(相对敌人中点进行偏移)")]
public Vector2 uiOffset;
/// <summary>
/// 自动锁定逻辑,敌人对象的填充在TargetScript.cs中完成
/// </summary>
private void UserInterface()
{
aim.transform.position = Camera.main.WorldToScreenPoint(target.position + (Vector3)uiOffset);
if (!input.canMove)
return;
// 如果敌人数量<1,图标就变透明
Color c = screenTargets.Count < 1 ? Color.clear : Color.white;
aim.color = c;
}
/// <summary>
/// 获取最近敌人的index
/// </summary>
/// <returns></returns>
public int targetIndex()
{
float[] distances = new float[screenTargets.Count];
for (int i = 0; i < screenTargets.Count; i++)
{
// 世界坐标转屏幕坐标,并且宽高/2取中点
distances[i] = Vector2.Distance(Camera.main.WorldToScreenPoint(screenTargets[i].position), new Vector2(Screen.width / 2, Screen.height / 2));
}
float minDistance = Mathf.Min(distances);
int index = 0;
for (int i = 0; i < distances.Length; i++)
{
if (minDistance == distances[i])
index = i;
}
return index;
}
- 最后设置下设置图片、Y轴偏移即可:
- 效果如下:
右键确认锁定敌人
- 这里补充下逻辑,技能是右键确认锁定后才能释放的
- 由于上面的代码、场景都加了确认锁定的定义、资源,所以这里就补充逻辑代码即可:
void Update()
{
// 略......
// 右键按下
if (Input.GetMouseButtonDown(1))
{
LockInterface(true);
isLocked = true;
}
// 右键松开 & 可瞬移状态(锁定之后就是可瞬移状态)
if (Input.GetMouseButtonUp(1) && input.canMove)
{
LockInterface(false);
isLocked = false;
}
// 到这里如果不是锁定状态就退出(下面的逻辑为按着右键锁定为前提,才继续进行的)
if (!isLocked)
return;
// TODO 后续技能逻辑
}
/// <summary>
/// 确认锁定
/// </summary>
void LockInterface(bool state)
{
float size = state ? 1 : 2;
float fade = state ? 1 : 0;
// state为True则0.15s内渐变显现,反之则渐变消失
lockAim.DOFade(fade, .15f);
// 0.15内变化大小至size, OutBack(先缓慢后快速的效果)
lockAim.transform.DOScale(size, .15f).SetEase(Ease.OutBack);
//原来有问题的代码
lockAim.transform.DORotate(Vector3.forward * 180, .15f, RotateMode.FastBeyond360).From();
aim.transform.DORotate(Vector3.forward * 90, .15f, RotateMode.LocalAxisAdd);
}
- 效果如下:
锁定操作过快的BUG
- 上面的操作是原本教程的代码,看似没有问题,但是在锁定、解锁操作频繁一些的操作下就很容易出现问题,问题表现如下:
- 说白了就是角度没旋转完:
- 这个BUG产生的原因也很简单,操作过快时,上一个动画没完成就立刻进入下一个动画了,导致旋转不完全
- 第一种修复方法,固定旋转的角度+不以自身偏移增加旋转角度(缺点:跟原来的逻辑不完全一致,但我觉得问题不大),代码修改如下:
/// <summary>
/// 确认锁定
/// </summary>
void LockInterface(bool state)
{
float size = state ? 1 : 2;
float fade = state ? 1 : 0;
// state为True则0.15s内渐变显现,反之则渐变消失
lockAim.DOFade(fade, .15f);
// 0.15内变化大小至size, OutBack(先缓慢后快速的效果)
lockAim.transform.DOScale(size, .15f).SetEase(Ease.OutBack);
// 以下是一种针对频繁旋转角度不对的优化,缺点是效果不完全一致
// 0.15s内旋转90°
aim.transform.DORotate(Vector3.forward * (state ? 90 : 0), .15f, RotateMode.Fast);
// 0.15s内旋转180,RotateMode.FastBeyond360表示超过360°也要快速旋转
lockAim.transform.DORotate(Vector3.forward * (state ? 180 : 0), .15f, RotateMode.Fast);
}
- 修复效果如下:
- 第二种修复方法,使用
DOComplete()
函数保证重复调用时完成上一次动画,代码修改如下:
void LockInterface(bool state)
{
lockAim.transform.DOComplete();
aim.transform.DOComplete();
float size = state ? 1 : 2;
float fade = state ? 1 : 0;
// state为True则0.15s内渐变显现,反之则渐变消失
lockAim.DOFade(fade, .15f);
// 0.15内变化大小至size, OutBack(先缓慢后快速的效果)
lockAim.transform.DOScale(size, .15f).SetEase(Ease.OutBack);
//原来有问题的代码
lockAim.transform.DORotate(Vector3.forward * 180, .15f, RotateMode.FastBeyond360).From();
aim.transform.DORotate(Vector3.forward * 90, .15f, RotateMode.LocalAxisAdd);
}
- 原本我以为到这里就结束了,但发现还有个坑爹的问题:
- 问题就在于
DOComplete()
对子对象不起作用,子对象无论如何都会产生一些偏差,如下图所示: - 最终解决的手法也比较粗暴(能试过的方法都试过了),不放置子对象中,拉出来单独设置坐标
- 首先是子对象分离:
- 代码改造:
private void UserInterface()
{
aim.transform.position = Camera.main.WorldToScreenPoint(target.position + (Vector3)uiOffset);
// 原本lockAim是aim的子对象,但如果为子对象则子对象的DOComplete()无法生效,所以拆出来了
// 拆出来后lockAim不会跟随父对象aim自己移动,所以坐标要手动修改
lockAim.transform.position = aim.transform.position;
// 如果敌人数量<1,图标就变透明
Color c = screenTargets.Count < 1 ? Color.clear : Color.white;
aim.color = c;
}
- 搞定,虽然暂时还不知道引起的原因是DOTween的bug(文档没看到相关说明,版本为Version 1.2.705 - October 10, 2022),还是本该如此,最终效果如下:
释放瞬移斩
- 下面这些特效先引入,到后面补充下Shader就算了,粒子系统不会讲解:
[Tooltip("自身变化材质")]
public Material glowMaterial;
[Tooltip("拖尾粒子")]
public ParticleSystem blueTrail;
public ParticleSystem whiteTrail;
[Tooltip("击打特效粒子")]
public GameObject hitParticle;
- 接下来补全
Update()
的最后一个逻辑:
void Update()
{
// 略......
// 鼠标左键按下
if (Input.GetMouseButtonDown(0))
{
input.RotateTowards(target); // 旋转一下自身面朝敌人
input.canMove = false;// 设置为不可移动
swordParticle.Play();// 剑的粒子播放
swordMesh.enabled = true;// 剑显现
anim.SetTrigger("slash");// 动作切换为技能释放
}
// 按Esc按键恢复鼠标显示
if (Input.GetKeyDown(KeyCode.Escape))
{
Cursor.visible = false;
}
}
- 同时
UserInterface()
也补上之前漏掉的canMove
的判断:
private void UserInterface()
{
aim.transform.position = Camera.main.WorldToScreenPoint(target.position + (Vector3)uiOffset);
lockAim.transform.position = aim.transform.position;
if (!input.canMove)
return;
Color c = screenTargets.Count < 1 ? Color.clear : Color.white;
aim.color = c;
}
- 编写释放技能后隐藏剑逻辑(核心是隐藏的同时放特效):
/// <summary>
/// 隐藏剑(播放剑的粒子特效)
/// </summary>
/// <returns></returns>
IEnumerator HideSword()
{
yield return new WaitForSeconds(.8f);
// 剑出现/消失粒子特效
swordParticle.Play();
// 克隆当前剑
GameObject swordClone = Instantiate(sword.gameObject, sword.position, sword.rotation);
// 使原来的剑关闭渲染(直接消失)
swordMesh.enabled = false;
MeshRenderer swordMR = swordClone.GetComponentInChildren<MeshRenderer>();
Material[] materials = swordMR.materials;
// 替换材质为自定义的闪光材质
for (int i = 0; i < materials.Length; i++)
{
Material m = glowMaterial;
materials[i] = m;
}
swordMR.materials = materials;
// 调整透明度让剑神外壳闪一下
for (int i = 0; i < swordMR.materials.Length; i++)
{
swordMR.materials[i].DOFloat(1, "_AlphaThreshold", .3f).OnComplete(() => Destroy(swordClone));
}
input.canMove = true;
}
- 控制主角身体是否显现的函数:
/// <summary>
/// 控制主角本体是否显现
/// </summary>
/// <param name="state"></param>
void ShowBody(bool state)
{
SkinnedMeshRenderer[] skinMeshList = GetComponentsInChildren<SkinnedMeshRenderer>();
foreach (SkinnedMeshRenderer smr in skinMeshList)
{
smr.enabled = state;
}
}
- 编写动画恢复播放的脚本:
IEnumerator PlayAnimation()
{
yield return new WaitForSeconds(.2f);
anim.speed = 1;
}
- 编写技能释放开始和结束的回调函数:
public void Warp()
{
// 隐藏原来的身体(克隆的被删掉了,所以不用隐藏)
ShowBody(false);
// 动画暂停
anim.speed = 0;
// 在warpDuration内移动到敌人处,完成后调用FinishWarp()
transform.DOMove(target.position, warpDuration).SetEase(Ease.InExpo).OnComplete(() => FinishWarp());
// 这里解不解除父对象效果都没太大变化,不给过也算是一种技巧吧
sword.parent = null;
// 剑要比人更快到达
sword.DOMove(target.position, warpDuration / 1.2f);
// AxisConstraint.None为没有轴向限制
sword.DOLookAt(target.position, .2f, AxisConstraint.None);
}
/// <summary>
/// 完成瞬移
/// </summary>
void FinishWarp()
{
// 重新显示本体
ShowBody(true);
// 重新挂载剑的父对象、复位
sword.parent = swordHand;
sword.localPosition = swordOrigPos;
sword.localEulerAngles = swordOrigRot;
// 生成击打特效
//Instantiate(hitParticle, sword.position, Quaternion.identity);
// 敌人受击
target.GetComponentInParent<Animator>().SetTrigger("hit");
// 敌人后退
target.parent.DOMove(target.position + transform.forward, .5f);
// 隐藏剑
StartCoroutine(HideSword());
// 动画继续播放(前面暂停了角色动画)
StartCoroutine(PlayAnimation());
// 解除锁定
isLocked = false;
LockInterface(false);
aim.color = Color.clear;
//Shake
// 相机受到一个向右的冲击力
impulse.GenerateImpulse(Vector3.right);
}
Warp()
函数是由动画中插入事件后调用的,当前效果如下:- 看似完成了,其实不然,还有个问题,就是现在的剑敌人脚的方向打过去的,打个断点可以看得更清楚:
- 根据之前写过一点2D的经验,怀疑是模型的原点不对,结果打开Blender一看,果然如此:
- 那我们只需要更改原点即可:
- 或者通过代码增加偏移量也可以,总而言之,以下是修复后的效果:
- 接下来就是把特效也加上去了
残影特效
- 这里的残影就是让身体消失,然后留下边缘光,由此很容易想到菲尼尔效应,下面先把逻辑代码写了,然后再说shader。
- 首先是逻辑代码,在Warp的开头克隆一个主角身体,然后只留下渲染组件即可,之后就是shader过渡参数:
public void Warp()
{
//克隆主角,并删除不必要的组件
GameObject clone = Instantiate(gameObject, transform.position, transform.rotation);
Destroy(clone.GetComponent<WarpController_Clone>().sword.gameObject);
Destroy(clone.GetComponent<Animator>());
Destroy(clone.GetComponent<WarpController_Clone>());
Destroy(clone.GetComponent<MovementInput>());
Destroy(clone.GetComponent<CharacterController>());
// 更改shader
SkinnedMeshRenderer[] skinMeshList = clone.GetComponentsInChildren<SkinnedMeshRenderer>();
foreach (SkinnedMeshRenderer smr in skinMeshList)
{
// 将着色器更改成glowMaterial
smr.material = glowMaterial;
// _AlphaThreshold属性5秒内变到2,并且完成后销毁
smr.material.DOFloat(2, "_AlphaThreshold", 5f).OnComplete(() => Destroy(clone));
}
// 略......
}
- shader graph如下所示,控制
_AlphaThreshold
可以起到类似溶解的效果:
拖尾粒子实现
Warp()
函数增加如下代码播放粒子动画:
public void Warp()
{
// 略......
// 播放粒子动画
blueTrail.Play();
whiteTrail.Play();
}
FinishWarp()
函数则增加停止播放调用:
void FinishWarp()
{
// 略......
// 隐藏剑
StartCoroutine(HideSword());
// 动画继续播放(前面暂停了角色动画)
StartCoroutine(PlayAnimation());
// 粒子系统停止
StartCoroutine(StopParticles());
// 解除锁定
isLocked = false;
LockInterface(false);
aim.color = Color.clear;
// 略......
}
/// <summary>
/// 停止拖尾粒子
/// </summary>
/// <returns></returns>
IEnumerator StopParticles()
{
yield return new WaitForSeconds(.2f);
blueTrail.Stop();
whiteTrail.Stop();
}
- 修改后的效果:
- shader graph实现(原教程的手写shader引用了2018版本的hlsl,这里就重新拉一个了):
打击特效 & 人物显现
- 人物显现代码如下:
// 调整shader中的_FresnelAmount参数(Shader是OpaqueGlow)
void GlowAmount(float x)
{
SkinnedMeshRenderer[] skinMeshList = GetComponentsInChildren<SkinnedMeshRenderer>();
foreach (SkinnedMeshRenderer smr in skinMeshList)
{
smr.material.SetVector("_FresnelAmount", new Vector4(x, x, x, x));
}
}
- shader graph实现还是上面的
GhostGlow
,只不过不是调透明度,而是调整_FresnelAmount
让RGBA都放大30倍(超过1则增大亮度),叠加之后不透明的地方就更多了,如下图所示: - 之后
FinishWarp
加上打击特效代码如下:
void FinishWarp()
{
// 略......
SkinnedMeshRenderer[] skinMeshList = GetComponentsInChildren<SkinnedMeshRenderer>();
foreach (SkinnedMeshRenderer smr in skinMeshList)
{
GlowAmount(30);
DOVirtual.Float(30, 0, .5f, GlowAmount);
}
//生成击打特效
Instantiate(hitParticle, sword.position, Quaternion.identity);
// 略......
}
- 最后再来一次慢动作视频: