不死鸟之翼吧 关注:309贴子:39,533

【Unity学习】终极第三人称摄像机代码·Version1 代码实现教程

只看楼主收藏回复

话说鸟儿发布AlgoCube以后,就一直在想我这边有没有什么技术可供分享。这个也算最近学习的成果,放在这里给自己做个备份也方便伸手党。
这是一个应用于3D动作游戏的整合代码,适合想做这方面游戏又不喜欢Unity内置摄像机代码的新人。
该代码实现了以下功能:
1.视线始终锁定人物背面,而不是对角色的转动毫无反应,便于攻击敌人;
2.与Unity内置代码一样,视线移动平滑,不会出现抖动;
3.跟踪角色的跳跃动作。其实功能3是功能1必然的结果,反倒是Unity专门写了代码使得人物跳跃时摄像机不跟踪。我个人认为Unity这套代码有点画蛇添足,不跟踪的话玩家使用轻功之类的怎么办呢?
4.在角色闲置或攻击时,锁定摄像机旋转。比如人物使出一记强力的回旋踢,这个时候摄像机就不能跟着转,不然造成的效果就是镜头里玩家没转,反倒是整个世界天旋地转,敌人的视野也丢失了。
该代码还存在一些缺陷:
人物贴近墙壁时,不能自动转为俯瞰。
很粗糙的整合,对同行的高手基本没什么帮助,不过如果能提点修改意见我也非常感谢。
【给伸手党的提醒】
本文的重点是教程,所以教程会放在前面,资源会放在正文最后。懒病治不好,多翻翻页总不介意吧。而且我的希望是最好在翻页途中,即使伸手党也可以去关注一下步骤操作,虽然是我这样的菜鸟写的教程,应该也能对提升技术有所帮助。


IP属地:上海1楼2014-12-04 11:11回复
    本楼用于存放目录,因为中间删了一楼,所以4L开始正文
    友情提示:请活用“只看楼主”功能


    IP属地:上海3楼2014-12-04 11:15
    收起回复
      老到的程序员总会告诉我们,学习代码的最好方法是改代码。不是说他们说的有错,而是说他们没有设身处地的为新人程序员着想。
      对我们这样的菜鸟来说,上来就接触跟自己不在一个段位的代码,就算学会了个别变量和语句,也不能拼凑到一起。
      更多的情况是,代码每一条语句并不复杂,但当海量的语句整合到一起,菜鸟们不知道哪些语句是互相关联的,哪些语句又属于另一项功能。除非这些代码写得很有条理,并且附以很详细的注释,否则Ctrl+F是解决不了问题的。
      遇到这种情况,老师傅一般会告诫新人要有耐心,坚持这种训练方法,的确总有一天可以理解代码的原理。但任何人都有懒惰、得过且过的一面,尤其是对于在艰深的代码学习中感到迷茫的学生来说,这一面更容易被放大,很容易在中途就放弃了。
      所以在我看来,学习代码最好的方法,其实是从零开始,先用最少的语句实现一个简单的功能,实现以后立刻看到效果,如果合适就加一点新的功能,否则先停下来改BUG。是把大的代码量分解成步骤容易,还是一上来就给一个完整的工程容易,这个类比马拉松,答案是很明显的。我相信优秀的程序员,也是这么写代码的。
      现实中大神们一般都很忙,他们没有时间系统的指导他人分步骤学习。如果你遇到愿意这样带你的大神,请务必珍惜机会。绝大多数情况下,只是我们这些学习中的人用这种方法分享学习经验罢了。


      IP属地:上海4楼2014-12-04 11:17
      回复
        我们今天要做的是摄像机跟踪。首先明确最基本的需求:使摄像机的位置始终处于角色背后一段距离,然后使摄像机注视角色。
        因为Unity里大部分脚本是绑在游戏物体身上才有用,所以也就分成两种方法:
        一种是脚本绑在角色身上,然后寻找用于跟踪的摄像机。Unity自带的第三人称摄像机代码是这么做的;
        另一种是建一个空物体sight作为角色的子物体,脚本绑在相机上,然后寻找这个sight。
        我们这里用的是第二种方法,原因很简单,角色身上一般还要绑好多脚本比如伤害判定,相机上一般就这么一个了。
        首先来做准备工作。Unity中的C#脚本一般分四个部分,我们新建一个名叫CameraControl的C#脚本,打开以后:

        这是一个新建的空代码,但也有和完整代码一样的结构。放在“类”也就是public class外面的只有区域1,就是脚本头,可以理解成用于存放Unity内置的代码资源。这次我们用到UnityEngine和System.Collection这两个就够了,在写其他功能的时候,可能要往这个区域加东西。
        (注意:public class后面的类名CameraControl必须和脚本文件名一致。脚本拖进游戏读不出来这种常见的BUG一般就是因为人为修改了文件名或类名。)
        剩下三个区域都放在类里面。区域2是定义变量的区域,如你所见,现在还没有定义任何变量。虽然在类里面任何区域都可以定义变量,但通俗一点说,一个变量只能影响同处于一个大括号底下的区域。所以在区域2定义的变量是可以影响整个类的。
        区域3是初始化函数,可以理解成游戏开始时调用。
        区域4是Update类函数,有好几个种类,像Update,LateUpdate,FixedUpdate,一般常用到的也就这三种。
        Update是每帧调用一次,LateUpdate是每帧等到所有Update函数全部执行完毕再调用,FixedUpdate是根据自己设定的时间间隔调用。
        我们这次保险起见使用LateUpdate,所以先在Update前面加个Late:

        你还可以在与区域3和4平行的位置写其他函数,并且自己起名,比如void Damage ()啦,void Die ()啦,但请注意,这些函数要么你自己写调用条件,要么一定要在区域3或4用到,不然函数不会起作用的哦。
        最后如果还需要废话什么那就是,你可以用双斜杠注释某一行文字,或者用/*开头,*/结尾来注释一段文字。注释的文字没有实际作用,图片里那些绿字就是这样。


        IP属地:上海5楼2014-12-04 11:23
        回复
          准备就绪,我们在区域2添加如下语句:

          区域2是定义变量的区域,那么这些就是定义的变量了。一共有三个public变量,两个private变量,还有一个孤零零的Quaternion rotation其实也是private变量。
          Unity里公共和私有变量都是什么意思呢,其实public就代表可以直接在Inspector面板查看并修改。

          在Unity的标准界面布局中,我用红色线条示意的右半区域就是Inspector面板。面板的左上角会有个小标签里面写着名字,应该找得到吧。我们把这张图的右下角放大一点看:

          这个是已经完成的CameraControl代码,红框里显示了这个脚本里所有的公共变量。如果在这里改动变量的数值,游戏运行时会按这里的数值走,而不是代码里的。
          不过相对的,public变量太多的话,程序的效率会低下。所以,不需要随时修改数值的变量最好设置为private。
          我们回到这张图讲解这些变量:

          第一个是游戏物体变量,它定义了一个名为sight的游戏物体,用来代表相机注视的目标;
          第二个是向量变量,它定义了一个三维向量(x,y,z)。Unity中默认x是左右方向,y是上下方向,z是前后方向。这个向量用于表示游戏开始时,注视点sight的坐标和摄像机坐标的差值。游戏开始后,我们要在Update函数里更新摄像机的位置,保证摄像机与角色间的距离始终与开始时一致,就要用到这个量啦;
          第三个是浮点型变量,简单地说就是精确到一定位数的小数变量。在C#中这种变量的特点是它的值后面总跟着一个字母f,比如这里的0.5f等于浮点值0.5.这个浮点型变量名叫velocity,是保证相机平滑运动的关键,我们待会再提;
          后面两个private变量也是浮点型变量,表示当前要让相机转动到哪个角度;
          最后一个Quaternion,也是(x,y,z)形式的变量,不同的是它不代表向量,而代表将一个向量沿x轴旋转x,沿y轴旋转y,沿z轴旋转z的【过程】。


          IP属地:上海6楼2014-12-04 12:41
          收起回复
            上面的变量里有一些还没有具体赋值,接下来就要在Start函数,也就是区域3中赋值。由于在Update函数中,这些变量的值我们要去变化,所以在Start函数中赋一个初值,保证每次重新开始时数值会被重设。假如某个变量从始至终数值不变,比如上面的velocity,那么定义的时候赋值就行了。

            这里需要理解的地方,主要是sight.transform.position。因为sight是一个游戏物体,所以这个变量的形式其实是gameObject.transform.position,表示某个游戏物体的三维坐标。当gameObject缺省的时候,transform.position就表示这个脚本所在的游戏物体的坐标,也就是摄像机坐标了。
            所以这个语句的含义是:令向量offset的值等于注视点坐标减去摄像机坐标。与我们前面对offset的描述是一致的。
            currentAngleX和currentAngleY都是浮点变量,在浮点数等于0的时候,后面的f可以缺省。
            还剩下一个sight没有赋值,怎么办呢?这个sight我们不在脚本里赋值,而在Unity里赋值。前面说过,public变量的值是可以在inspector面板里修改的。
            剩下的就是Update函数了。我们一步一步来,先写出以下语句:

            这里sight.transform.eulerAngles,与前面Quaternion一样,指的是sight这个物体绕某个轴旋转的角度,后面是.x就是绕x轴,后面是.y就是绕y轴。如果后面没有东西,那就是一个(x,y,z)数组。
            我这里让相机跟随的方法是,用sight的转动去驱动向量offset的转动,然后每一帧都计算sight的坐标减去offset,就得到这一帧摄像机所应该在的坐标。当摄像机移动到这个坐标后,再用transform.LookAt转动摄像机,使其对准sight。这个过程可以用下图形象的表示:

            这就带来一个问题,sight的旋转轴和offset的旋转轴不一定是一致的。而且有一个轴的旋转不需要传递给摄像机,那就是sight的前后轴z轴。想像一下绕着前后轴旋转的摄像机,就好像歪着脑袋看东西。
            经过试验,我们应该将sight的x轴旋转传递给offset的z轴,y轴旋转仍旧传递给y轴。我们建立了desireAngleX,desireAngleY两个中间量,用于传递这两个旋转。这两个变量的含义就是我们期望向量最终旋转的角度。
            完成以后就可以保存这个脚本,回到游戏中,把脚本拖到摄像机名字上,再调整摄像机的位置使其对准人物。

            然后新建一个空物体,起一个自己记得住的名字,把它移动到角色的头部附近。


            调整好位置之后,选择空物体把它拖到角色名字上,使其成为角色的子物体。
            最后我们选择Camera,此时已经可以看见右边的CameraControl脚本:

            还记得我们有一个游戏物体变量sight没有赋值吧,把刚才的空物体拖到sight后面的空格,令sight的值等于这个空物体。听起来很奇怪,但游戏物体变量的值就是一个“东西”。
            现在运行游戏,可以看到大部分的功能已经实现了。


            IP属地:上海7楼2014-12-04 16:36
            回复
              赞!


              IP属地:北京来自Android客户端8楼2014-12-04 23:08
              收起回复
                通过刚才的脚本我们已经实现了摄像机跟踪,但并没有消除抖动。
                我们先来思考一下什么是平滑跟踪。到现在为止我们所写的跟踪都是角色转动时,向量【立刻】跟着转动。显然,所谓平滑跟踪就是降低跟踪的速度,也就是滞后一段时间才到达“我们期望的角度”desireAngle。
                还记得我们一开始定义的变量“当前角度”currentAngle吗,这个变量的用处就在这里。我们在Update函数里时刻计算currentAngle和desireAngle的中间值,然后把这个值作为新的当前角度,如此便能实现平滑跟踪。
                原来的第三人称摄像机代码里是使用SmoothDamp进行中间值计算,我们这里使用Lerp。我们在刚才写的LateUpdate函数里加入红框部分的代码,使其变为如下形式:
                Mathf.LerpAngle表示将currentAngle以velocity大小的速率转变为desireAngle时的中间值。而velocity的值我们一开始就定义好了,还记得吧?

                然后,我们将下面这一行中的desireAngle全部替换为currentAngle:

                这样就完成了平滑跟踪的功能。
                如果你对摄像机跟踪的反应速度不满意,通过调整velocity的值就可以进行修改,变化区间在0-1之间。等于1的时候,就相当于关闭平滑跟踪。


                IP属地:上海9楼2014-12-10 13:03
                回复
                  我们只剩最后一个功能没有完成:当角色处于闲置或攻击状态时,锁定旋转,使得玩家能拥有稳定的视野。
                  当然啦,如果你的角色还不具备闲置和攻击动作,那这个功能也就没必要了,先做好人物动画设置再来看吧。
                  我们这里针对已经有一定人物动画知识的制作者进行讲解。大家知道Unity4.x以前的动画系统都是Animation,4.x以后开始使用新的MechanicSystem也就是Animator。
                  我这个脚本主要针对新动画系统。旧动画系统的话,稍加修改也能使用。
                  我们默认人物已经有了一个Animator,以及一个闲置动作Idle和若干攻击动作(攻击动作一般不可能只有一个吧):

                  逐一选中你要的攻击动作,在右上角把Tag改为“Attack”,其实就是统一一下标签:

                  然后,为了使脚本能识别这个Animator,我们在定义变量的区域2定义一个Animator变量:

                  在LateUpdate函数开头用GetCurrentAnimatorStateInfo方法定义一个奇怪的变量:

                  这个stateInfo变量,是用来记录当前我们在播放Animator里的哪个动画的。比如当前正在播放闲置动画,那么stateInfo的值就等于字符串”Idle”。
                  但是!!GetCurrentAnimatorStateInfo这个方法不支持直接读取字符串。我们得在区域2先把表示闲置动画的”Idle”写成如下形式:

                  readonly表示只读,这个方法只允许使用只读类型的string变量,所以我们把”Idle”只读化一下就ok啦。
                  到这儿有人要问了:闲置状态只有一个所以这样写就可以了,但万一有成百上千个攻击动作,岂不是每一个动作的名字都要在这里只读化一下?
                  别急,也有不需要名字就可以读取动作状态的方法。还记得我们刚才改了Tag吗,现在我们在上面的语句下面再加一行:

                  我们把所有攻击动作的标签”Attack”定义为一个字符串AtkTag,这样以后每添加一个攻击动作,我们把它的Tag改为Attack即可,代码只去识别动作的Tag。
                  准备工作完成之后,我们找到LateUpdate函数里的这句话:

                  我们需要给这句话添加一个条件,即当角色既不处于闲置状态又不处于攻击状态时,rotation才等于当前角度,否则就保持不变。条件判断当然要用If,不过else就不用写了,反正保持不变嘛。
                  把这句话改写为:

                  当stateInfo的Tag不等于“攻击”,名字不等于“闲置”时,摄像机跟随转动,与我们的需求一致。
                  保存一下,返回Unity,我们跟之前一样,在Unity里为代码里的animator变量赋值。
                  找到绑上Animator的人物角色,把它拖到CameraControl代码animator后面的空格处:

                  好了,现在全部功能已经完成,在全新的脚本下享受流畅的镜头吧!
                  至于老动画系统,道理是一样的,只不过读取的由Animator的状态变为Animation而已。
                  完整代码稍后会在楼下放出,我会把老动画系统的代码一块写好后发出来。


                  IP属地:上海10楼2014-12-10 13:17
                  回复
                    新动画系统的代码如下,调整了一下顺序,把同种类型的变量放在一块,方便大家理解:
                    using UnityEngine;
                    using System.Collections;
                    public class CameraControl : MonoBehaviour {
                    public Animator animator;
                    public GameObject sight;
                    public float velocity = 0.5f;
                    private Vector3 offset;
                    private float currentAngleX;
                    private float currentAngleY;
                    Quaternion rotation = Quaternion.Euler (0, 0, 0);
                    private static readonly string IdleState = "Idle";
                    private static readonly string AtkTag = "Attack";
                    void Start () {
                    offset = sight.transform.position - transform.position;
                    currentAngleX = 0;
                    currentAngleY = 0;
                    }
                    void LateUpdate () {
                    AnimatorStateInfo stateInfo = animator.GetCurrentAnimatorStateInfo(0);
                    float desireAngleX = sight.transform.eulerAngles.y;
                    float desireAngleY = sight.transform.eulerAngles.x;
                    currentAngleX = Mathf.LerpAngle (currentAngleX, desireAngleX, velocity);
                    currentAngleY = Mathf.LerpAngle (currentAngleY, desireAngleY, velocity);
                    if (!stateInfo.IsTag (AtkTag) && !stateInfo.IsName (IdleState))
                    {
                    rotation = Quaternion.Euler (0, currentAngleX, currentAngleY);
                    }
                    transform.position = sight.transform.position - (rotation * offset);
                    transform.LookAt (sight.transform);
                    }
                    }
                    由于百度不能识别段前空格,我把代码编辑器的截图也发出来,大家照着格式换行就行:


                    至于老动画系统嘛……用animation.IsPlaying()可以得知当前某动画是否在播放(括号内为动画名)。我们可以定义一个新的GameObject变量player,在Unity里赋值为角色,然后编写以下函数:
                    public static string GetCurrentPlayingAnimationClip(GameObject player)
                    {
                    if (player == null)
                    {
                    return string.Empty;
                    }
                    foreach (AnimationState anim in player.animation)
                    {
                    if (player.animation.IsPlaying(anim.name))
                    {
                    rotation = Quaternion.Euler (0, currentAngleX, currentAngleY);
                    }
                    }
                    return string.Empty;
                    }
                    其中if部分的判断条件还是要自己按这个格式写。
                    最后我们把LateUpdate里原来的if语句整体替换为:
                    GetCurrentPlayingAnimationClip(player);这样就可以了


                    IP属地:上海11楼2014-12-11 23:18
                    回复
                      全文结束


                      IP属地:上海12楼2014-12-11 23:19
                      回复
                        楼主辛苦了


                        13楼2015-04-16 10:41
                        回复
                          说实话最近看D3D发现数学功底拙计了


                          IP属地:北京来自Android客户端14楼2015-05-02 16:33
                          收起回复
                            楼主 代码有问题吧


                            IP属地:浙江15楼2015-07-04 12:21
                            回复
                              为什么X轴旋转角度传递给Z轴呢 ,楼主 试验过吗
                              当人物旋转的时候,摄像机能正确移动到响应的位置吗?


                              IP属地:浙江16楼2015-07-04 12:23
                              收起回复