前言

  • 趁着端午假期+调休,折腾了一丢丢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);
    }
}

  • 把脚本挂载到空物体上,效果如下:
  • 视野内的敌人会保存到List中

增加敌人锁定标识

  • 现在场景中增加Canvas,并增加两张锁定图标图片:
  • 场景中增加图片
  • image-父对象-预锁定
  • image(1)-子对象-确认锁定
  • 之所以把【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轴偏移即可:
  • 设置图片、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演示
  • 说白了就是角度没旋转完:
  • 角度1
  • 预锁定错误
  • 角度2
  • 确认锁定错误
  • 这个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可以起到类似溶解的效果:
  • GhostGlow shadergraph
  • 调整_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 graph实现

打击特效 & 人物显现

  • 人物显现代码如下:
    // 调整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则增大亮度),叠加之后不透明的地方就更多了,如下图所示:
  • 调整_FresnelAmount
  • 之后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);
        // 略......
    }
  • 最后再来一次慢动作视频: