标签归档:Unity3D

C# Socket如何判断连接已断开

相信很多Unity3D的程序员都会遇到网络编程的情况,虽然目前可能大部分的手游都会优先选择使用非长连接技术来实现,也就是我们常说的直接使用Http来完成跟服务器通信的部分,但是还是难免会有遇到需要使用实时网络连接的时候。那么这时候,我们通常还是需要回到网络编程的根源,那就是直接使用Socket进行编程,.NET Framework中的Socket已经封装得非常的完备了,真的很好使。

但是在使用的过程中难免还是会碰到一丢丢问题的,对吧。今天我要记录的就是在处理连接已断开的这种情况。

在手游的使用场景中,以下情况可能会出现客户端与服务器出现无法连接的情况:

  1. 客户端无法连接到网络,例如:客户端设备当前接入的Wi-Fi热点无法连接到互联网,客户端设备进入无线网络信号很差的地方(例如某些大厦的电梯,某些地铁线路等等),这个时候其实客户端和服务器都没有主动关闭过这个TCP连接,但是网络已经无法正常连接了;
  2. 服务器主动关闭了这条TCP连接,服务器为什么要主动关闭掉我的这条TCP连接呢?设想一下,客户端跟服务器建立TCP连接之后,客户端在10分钟内都没有向服务器发送过任何数据,那么服务器还有必要保留这条连接吗?如果当前在线的玩家较多,服务器可用的连接数已经达到一个容限值,那么是否需要及时将某些已经不活跃(例如10分钟以上没有发送任何消息)的连接主动关闭,回收这些连接资源,以备其他新登录游戏的玩家设备连接使用呢?所以,当服务器主动关闭了TCP连接的时候,10分钟我上完厕所回来了,我重新拿起我的手机,想继续玩我的游戏,那么就需要处理客户端如何重新连接到服务器的问题了;
  3. 服务器网络出现问题,例如服务器所在机房链路出现问题,通过某些网络就是无法访问服务器,例如服务器网线不小心被挖断了或者网线被网管拔了,服务器电源烧了,主板坏了,等等诸如此类的物理设备出现故障的情况下,客户端是无法连接到服务器的,服务器也不可能通过TCP协议给客户端发送什么FIN,ACK,SYN等等状态码啦,这个时候客户端也需要有正常的逻辑来处理这个情况。

其实说了这么多就是想表达一下,我们的客户端可能在任何情况下与服务器失去连接,而这个时候客户端需要合理地来处理这种连接已断开的情况。

我们可以查看一下.NET Framework的文档,看看Socket中的一些相关的属性和方法:

Socket.Connected Property

Gets a value that indicates whether a Socket is connected to a remote host as of the last Send or Receive operation.

The Connected property gets the connection state of the Socket as of the last I/O operation. When it returns false, the Socket was either never connected, or is no longer connected.

The value of the Connected property reflects the state of the connection as of the most recent operation. If you need to determine the current state of the connection, make a nonblocking, zero-byte Send call. If the call returns successfully or throws a WAEWOULDBLOCK error code (10035), then the socket is still connected; otherwise, the socket is no longer connected.

If you call Connect on a User Datagram Protocol (UDP) socket, the Connected property always returns true; however, this action does not change the inherent connectionless nature of UDP.

这个是Connected属性的相关描述,大体的意思就是这个Connected属性会根据当前这个Socket最后一次的I/O操作来进行设定,也就是说这个属性为True还是为False会在Socket最后一次I/O操作完毕之后进行设定,应该也是根据错误返回码SocketError来进行设定的。如果我们使用的是阻塞的Socket的话,那么这个状态会在最后一次I/O操作出错后将这个属性设置为False,如果使用的是非阻塞的Socket就需要我们自己通过发送一个零字节的数据包,然后根据错误返回码来判断Socket连接是否断开。


 

Socket.Receive Method (Byte[], Int32, Int32, SocketFlags, SocketError)

Receives data from a bound Socket into a receive buffer, using the specified SocketFlags.

在我们的项目中我们使用了这个方法来读取Socket中的数据 ,这个方法中的最后一个参数是一个out参数,也就是如果我们在读取数据的时候出现了一些异常,错误码会通过这个参数返回。


Socket.Send Method (Byte[], Int32, Int32, SocketFlags, SocketError)

Sends the specified number of bytes of data to a connected Socket, starting at the specified offset, and using the specified SocketFlags

我们使用这个Send方法来进行数据的发送,同样也是通过最后一个out参数SocketError来进行错误码的返回。

这个SocketError类型中呢有很多很多预定义的错误码,这个需要我们仔细查阅看看有哪些是我们需要使用到的,这个因项目类型和需求不同,这里不一一阐述了。

那么在我们实际的开发过程中呢,我们肯定可以通过判断SocketError错误码来进行一些异常情况的处理,不过有些时候我们其实并不想在出现异常了之后才处理,例如我有一个发送消息的队列,有一条消息发送出错了,我还得把这条消息再放回队列,可是如果直接入队到消息队列的队尾可能会有消息先后顺序的问题等等。所以有些时候我们就需要在异常出现之前就想好解决方法,也就是在我们读取或者发送数据之前就先检查一下连接是否正常消息是否能够成功发送出去,那么这又要肿么搞呢?

先看看Poll方法的描述吧:

Socket.Poll Method

Determines the status of the Socket.

Remarks

The Poll method will check the state of the Socket. Specify SelectMode.SelectRead for the selectMode parameter to determine if the Socket is readable. Specify SelectMode.SelectWrite to determine if the Socket is writable. Use SelectMode.SelectError to detect an error condition. Poll will block execution until the specified time period, measured in microseconds, elapses. Set the microSeconds parameter to a negative integer if you would like to wait indefinitely for a response. If you want to check the status of multiple sockets, you might prefer to use the Select method.

 看上去这个方法貌似就是我们想要的呢,对吧?如果你这么想那就太Naive了,在实际使用的过程中,我就犯了一个不仔细看文档,导致栽了一个大跟头的错误。我写了一段代码用来判断当前Socket是否已经跟服务器断开连接。

Socket socket;
// 省去初始化和连接服务器代码若干
if (!socket.Poll(1000, SelectMode.SelectRead)) {
// 做错误提示等相关的操作
}

事实证明这样是瞎搞,因为.NET Framework的文档关于SelectMode.SelectRead这样描述的。

true if Listen has been called and a connection is pending;
-or-
true if data is available for reading;
-or-
true if the connection has been closed, reset, or terminated;
otherwise, returns false.

你说这不是坑爹是啥?这个意思就是说,如果我们是一个服务器端的Socket,调用了Listen方法,并且有一个外部连接在等待我们处理的时候,这个Poll方法会返回true,如果当前Socket中有数据可以读取时,这个Poll方法也会返回true,或者当这个Socket连接被关闭、重置、强制终止了也会返回true,否则返回false。

实际测试的时候,发现这个值只在Socket缓冲区中有数据可以读取和连接被服务器关闭了之后才会返回true,好吧,我承认自己是猪吧。那么回到我们的初衷上来,我们还是希望能在读取数据之前就能知道这个连接是否已经被关闭了(是否与服务器断开连接了)。那么可以通过下面这段代码来进行判断。

// 省去初始化和连接服务器代码若干
if (socket.Available == 0 && socket.Poll(1000, SelectMode.SelectRead)) {
// 因为根据文档的描述,如果连接正常的话,这两个条件不可能同时成立
// 只有在连接已断开的时候,才可能会出现这两个条件同时成立的情况
}

写到这里的时候,突然之间很想看看Available这个属性又是怎么一回事,好吧,我们再来看看.NET Framework的文档吧。

Socket.Available Property

Gets the amount of data that has been received from the network and is available to be read.

Remarks

If you are using a non-blocking Socket, Available is a good way to determine whether data is queued for reading, before calling Receive. The available data is the total amount of data queued in the network buffer for reading. If no data is queued in the network buffer, Available returns 0.

If the remote host shuts down or closes the connection, Available can throw a SocketException. If you receive a SocketException, use the SocketException.ErrorCode property to obtain the specific error code. After you have obtained this code, refer to the Windows Sockets version 2 API error code documentation in the MSDN library for a detailed description of the error.

虽然文档中提到如果我们使用的是非阻塞的Socket的话,那么Available属性将会是一个用于检测Socket缓冲区中是否有数据可以Receive的好选择,这样我们如果使用阻塞的Socket难道就不能用了吗?当然也不是,肯定还是可以用的,只是说非阻塞的Socket如果一直循环调用Receive很有可能会做无用功,因为如果当前Socket缓冲区中没有数据可以读取的话,Receive方法会很快速的结束调用并且抛出一个SocketException,而如果每次Receive之前检查一下变量的成本相对于调用一次Receive方法可能开销会更小一些吧。而对于阻塞的Socket来说,每次Receive调用都是阻塞直到有数据返回,或者超时后抛出一个超时异常。所以我们阻塞的Socket也是可以用Available这个属性来进行提前判断的。但是实际测试中,Unity3D中使用的Mono中的Socket实现并非和.NET Framework的文档描述一致。关于如果Remote Host关闭连接之后,再调用这个Available属性会抛出异常的行为就没有。

 关于SelectMode中另外两个SelectWrite和SelectError类型,显然我们也不能放过啊。一一测试之后发现,在服务器主动断开连接之后,通过Socket.Poll(1000, SelectMode.SelectWrite)返回一直都是true。即便我们通过Send方法发出去的字节数为0,返回的SocketError中的错误码为Shutdown,依然不为所动啊,所以对于这个SelectMode.SelectWrite我已经凌乱了,希望哪天可以找到合理的解释。

但是Socket.Poll(1000, SelectMode.SelectError)在服务器断开连接之后会在第一时间内从false变成true,貌似这个还是蛮靠谱的,但是看了一下文档之后我又有点凌乱了。

true if processing a Connect that does not block, and the connection has failed;
-or-
true if OutOfBandInline is not set and out-of-band data is available;
otherwise, returns false.

这个意思是说,如果正在建立一个非阻塞的连接,然后这个连接失败了会返回true,或者没有设置OutOfBandInline但是又收到了out-of-band数据的时候会返回true,否则就返回false。

综合上面说的那一大堆东西,最终得出的结论就是Connected字段可以用,但是有滞后性,如果想通过这个字段来检测连接是否已经断开的话,就要明白这个字段是在我们通过Receive或者Send方法成功进行一次I/O操作之后,系统根据最后一次I/O操作的错误返回码来设定Connected字段的值的。

而如果想在进行I/O操作之前就检测到连接出现断开的情况的话,是可以通过Poll来达成目的的,当然这个会比较纠结。首先Poll配合SelectMode.SelectRead和SelectMode.SelectWrite都不能非常直接的反馈连接是否被断开 。但是貌似这个SelectMode.SelectError还是蛮靠谱的,所以呢,从目前测试得到的结果中,可以使用这个来提前判断连接是否已经断开。

不过在实际的应用中,我个人倒是更倾向于在使用SelectMode.SelectError做判断的同时,一定还要对在Receive和Send方法中返回的SocketError进行检查,这个返回码是直接从Native层返回的Socket错误码,非常可靠也很能说明问题。例如”HostDown”、”ShutDown“、”NetworkDown“等等状态码还是很能直观地反应当前Socket的状态的,所以还是需要好好地看看.NET Framework中关于SocketError的描述文档。

Unity3D中的碰撞检测

在游戏制作过程中,碰撞检测肯定是经常需要使用到的,例如子弹飞行之后与可攻击目标之间的碰撞,子弹飞行之后与环境中物体的碰撞等等,都需要用到碰撞检测。那么在Unity3D中Collider肿么使呢,又有啥需要注意的捏?

Unity3D中用于碰撞检测的组件叫Collider,由这个货派生出来的又有Box Collider,Sphere Collider,Capsule Collider,Mesh Collider和Wheel Collider,这里我们主要说一下我自己用过的几个货啊,我目前就使用过Box Collider,Capsule Collider和Mesh Collider,其实Sphere Collider应该也跟Box Collider、Capsule Collider没啥实质性的区别,只是适用于不同的物体罢了。不废话了,进正题。

Unity3D中使用碰撞需要注意些什么呢?

  1. 确保物体所处的Layer之间存在碰撞。在Unity3D中,如果需要两个物体之间发生碰撞,首先要确认这两个物体所在的层(Layer)之间是会有物理碰撞的,这个可以通过Edit->Project Settings->Physics打开PhysicsManager进行查看和编辑,编辑面板最下方有一个跟9×9乘法表一样的Mask(掩码)开关面板,如果想让A层和B层之间的物体有物理碰撞,那就找到A和B交叉的那个点,确认勾选上就可以了,如果想去除两个层之间的物理碰撞检测,那么就把勾选去掉就OK了,两个处于同一层的物体碰撞设置也同理哦。
  2. 确保用于碰撞检测的两个物体至少有一个物体上有Rigidbody组件,Unity3D的物理引擎实际上使用的是Nvidia的PhysX系统,这个引擎的所有计算都是通过Unity3D中的Rigidbody来进行表现的,所以这个Rigidbody是碰撞检测的必要条件。
  3. OnTriggerEnter和OnCollisionEnter的区别,理解这两个概念之前需要先了解Collider和Trigger的区别。在Unity3D中我们添加的组件都是Collider但是每种Collider组件都有一个属性『Is Trigger』,只要将这个属性设置为True,那么这个Collider就成为了Trigger,这两者的区别在于:
    • Collider之间(两个都没有设置Is Trigger属性为True)碰撞(接触)之后会回调Collider所在GameObject上绑定的脚本中的OnCollisionEnter方法,碰撞之后物理引擎会根据碰撞物体的质量,运动速度等等信息进行计算,计算碰撞之后物体的运动参数,会产生真实的物体碰撞效果。
    • Collider和Trigger之间(任意一个Collider设置了Is Trigger属性为True)碰撞(接触)之后回回调Trigger所在GameObject上绑定的脚本中的OnTriggerEnter方法,碰撞之后的物体依然会按照原有的运动状态继续在世界中运动,两者之间不会相互影响对方的状态。
    • Trigger之间(两个Collider都设置了Is Trigger属性为True)碰撞的情况同Collider和Trigger之间的碰撞。
    • OnTriggerEnter和OnCollisionEnter不会同时回调,永远都只会调用这两个方法中的其中一个。

Unity3D中Generic动画导入设置和Root Motion之间的关系

Unity3D的Mecanim动画系统可以直接复用3DS MAX中制作的动画文件中的位移,这个就是通过applyRootMotion来达成的,我们只需要在使用Animator控制动画播放的同时,设置Animator的applyRootMotion字段为True就OK了。

那么怎么来利用这个特性达成我们想要的一些效果呢?这个applyRootMotion到底指的是啥呢?

ApplyRootMotion,从字面上理解来看,是『应用根节点的运动』,听起来貌似像那么一回事。可是我们可以从官方文档上看到这样一段话:

The Root Transform is a projection on the Y plane of the Body Transform and is computed at runtime. At every frame, a change in the Root Transform is computed. This change in transform is then applied to the Game Object to make it move.

翻译过来的意思,应该是这样的:

根节点的运动变换其实就是整个物体运动变换通过Y轴垂直在水平面上的一个投影。根节点的运动变换在动画的每一帧中都会进行计算。计算出来的根节点变换结果都会应用在播放动画的对象上,让该对象按照根节点的运动变换进行移动。

这段话大体的意思就是,RootMotion这个玩意就是作用于动画物体在X轴和Z轴上的位移的,而且这个位移是根据实际播放的动画中每一帧物体的位移在X和Z轴上投影计算出来的。

这个特性非常赞特别是对于某些技能动画,整个动画是有一定位移的,但是动画的位移是动作设计师在设计时根据动作需要调出来的,位移是跟动作的幅度直接相关和匹配的。

那么在释放技能的时候就只需要直接播放动画,并且应用这个Root Motion的特性就可以很好的完成角色在播放攻击动作的同时进行移动,动作播放完毕之后就在动画结束帧角色所在的位置,切换为待机动作就OK了。

看起来很牛逼的样子对不对?是的,确实很牛逼。但是还有很多事情需要我们都一一了解以后,我们才能做出我们想要的东西的。

下面我们先岔开一下话题,好好说说这个Animation Import Settings中『Animations』Tab页中各项设置的作用。

  • Import Animation,勾选这个才可以导入动画到Unity工程中;
  • Bake Animations,这个选项只在使用Humanoid动画并且使用到了IK特性的时候才可用;
  • Anim.Compression,这个是关于动画压缩选项的,默认会选择Keyframe Reduction这个是『压缩关键帧』,就是Unity会自行重采样动画的关键帧,还有两个选项『Off和Optimal』,一个是关闭动画压缩,一个是最优化压缩(应该是压缩效率最高,动画效果失真度可能也较高)
  • 选择了Keyframe Reduction或者Optimal压缩选项,就会有三个用于控制压缩选项的系数配置, Rotation Error,Position Error和Scale Error,这个三个参数默认都是0.5,越小呢精度就越高也就是说动画的失真度越小。
  • Clips,这个下面列出了这个FBX文件下包含的所有动画,我们在默认的动画文件基础上新建和删除动画片段(Animation Clip),当然每个动画片段都是可以指定起始帧和结束帧的; 以下的设置都是针对单个动画片段滴:
    • Loop Time,勾选这个选项之后,如果Animator处于播放这个动画状态时,在播放完第一遍这个动画片段之后,会自动循环从起始帧再次开始播放动画,如此循环往复。如果我们不勾选这个选项,例如Animator一直处于播放这个动画的状态,那么动画会定格在动画的结束帧,直到我们通过Animator切换这个Animator状态机的状态,切换到其他的动画;
      • Loop Pose和Cycle Offset,在勾选了Loop Time之后生效的两个选项,Loop Pose用于控制动画循环播放时,从结束帧切换到起始帧时,动画的动作可以无缝的衔接上,Cycly Offset就是用于控制循环的时候起始帧偏移用的;
    • Root Transform Rotation,根节点的旋转信息
      • Bake Into Pose,勾选后会将根节点每一帧的旋转方向信息烘焙到动画的骨骼运动中,在整个动画播放的过程中,根节点的旋转信息就不会在通过Root Motion作用到播放该动画的GameObject上了,这就意味着这个动画播放的过程中,该物体的Transform中的Rotation值不会因为动画中物体做了任何旋转而发生改变,而是会保持一个恒定的值,和该动画播放之前的旋转值保持一致;
      • Based Upon (at Start)或者Based Upon,根节点旋转的参考基准,有两个选项『Original和Root Node Rotation』这两个分别指的是动画文件中指定的旋转值和根节点旋转信息,其实我更愿意将Original理解为动画中原点的旋转值,因为在整个动画播放的过程中,所有骨骼肯定都会有旋转和位移的变换,但是动画的原点其实一定都是确定的,这样理解感觉更简单也更形象一些,勾选了Bake Into Pose之后,就会变成Based Upon而不勾选Bake Into Pose就会保持为Based Upon (at Start),这个目前还木有理解为啥;
      • Offset,旋转角度与参考基准的偏移(以度为单位);
    • Root Transform Position(Y),根节点位移信息(Y轴)
      • Bake Into Pose,勾选后会将根节点每一帧在垂直Y轴方向上的运动信息烘焙到动画的骨骼运动中,在整个动画播放的过程中,根节点在Y轴方向的所有位移信息不会通过Root Motion作用到播放该动画的GameObject上,这就意味着我们在场景中看到物体在Y轴上有位移,例如向上或者向下移动,但是该物体的Transform中的Position信息不会发生改变,会跟动画播放之前的Position信息保持一致;
      • Based Upon或者Based Upon (at Start),这个貌似有点不一样哦,在选中Bake Into Pose之后会变成Based Upon (at Start),不勾选的时候是Based Upon,不过这个就能理解了。不烘焙的话,那么Root Motion中Y轴的变化就依赖于选择的『Original或者Root Node Position』的Y轴位移变化,如果选择烘焙的话,那么就以这个动画的起始帧的Y轴作为整个动画Root Motion的Y轴位移,在整个动画播放的过程中,Y轴的位移都是恒定不变的;
      • Offset,垂直方向上的偏移;
    • Root Transform Position(XZ),根节点位移信息(水平面,XZ轴)
      • Bake Into Pose,勾选后会将根节点每一帧在水平面(X和Z轴)方向上的运动信息烘焙到动画的骨骼运动中,在整个动画播放的过程中,根节点在X和Z轴方向的所有位移信息不会通过Root Motion作用到播放该动画的GameObject上,这就意味着我们在场景中看到物体在水平面上移动,但是该物体的Transform中的Position信息不会发生改变,会跟动画播放之前的Position信息保持一致,假如动画中物体会向前移动3米,我们会看到物体在整个动画播放过程中确实在向前移动,播放到最后一帧时确实向前移动了3米,但是当这个动画播放完毕之后,切换到任何其他的动画时,物体会直接闪回这个动画播放前物体所在的位置,所以通常我们需要保留动作位移的动画都不会勾选这个选项。那这个选项有神马用捏?例如某些待机动画,我们其实希望物体只是做一个待机动作,但是实际上不想让物体在水平方向上有位移,这个时候就可以勾选这个选项了,到时候看起来物体就像是钉在水平面上了;
    • Mask,这个掩码主要是用于控制动画播放过程中,各个骨骼之间的运动变换的
      • Definition,可以选择从动画文件创建也可以选择使用其他动画文件中已经创建好的配置;
      • Transform,这个就是动画文件中所有骨骼的层级关系,可以选择勾选那些需要应用动画中运动变换的骨骼;
    • Curves,这个主要用于设置某些跟动画相关的参数用,例如控制整个动画播放过程中的速度参数之类的,在动画播放的过程中可以通过Animator.GetFloat(ParamName)函数来读取曲线的值,曲线的X轴为动画的时间轴,Y轴为曲线的值,曲线可以通过曲线编辑器进行增加关键点,调整曲线斜率进行编辑,读取时默认会根据当前动画播放的进度作为X轴的值进行读取,一个动画片段可以有多个曲线;
    • Events,这个是用于在动画播放的过程中触发事件的,例如整个动画中有起跳和落地两个事件需要在准确的时间点触发并通知到游戏中其他的对象,那么就可以在Events时间轴上新增事件通知,设置好触发的方法名称和参数,在播放该动画的GameObject上确保有某个脚本中有与该事件通知的方法签名一致的方法就好了,当动画播放到触发通知时间时,就会向GameObject广播该时间通知,脚本中方法签名一致的方法就会被回调了,那我们就可以做我们需要做的事情了。

说了这么多貌似跟Root Motion不是很相关的东西,那么究竟我们今天的主题是啥呢?肯定还是Root Motion这货。主要因为动画导入时的设置对于Root Motion的应用影响非常直接,所以前面絮絮叨叨地把这个动画导入设置都罗列了一遍。

回到正题,Generic动画应用Root Motion有以下几个特点:

  1. Root Motion仅仅作用于GameObject在X和Z轴上的位移变换,不影响Y轴上的位移。例如现在播放一个从地上向前空翻之后落地的动画,设置Animator的applyRootMotion为True,也就是应用Root Motion,那么动画在播放过程中,物体会在水平方向和垂直方向上都按照实际动画的运动轨迹进行运动,如果将applyRootMotion设置为False,那么我们就只能看到动画在原地起跳然后再落地,动画中原本应有的在水平方向的位移就没有了;
  2. Root Motion与导入动画时设置Root Transform Position(XZ)是直接相关的,如果我们选择了将X和Z轴方向上根节点的位移烘焙到动画骨骼运动中的话,那么动画播放过程中不论我们是否将Animator的applyRootMotion设置为True还是False,动画播放过程中物体在X和Z上的移动是一定的,因为这个已经被烘焙到骨骼动画中,只要动画播放,物体就会移动,但是在动画播放的过程中GameObject的Position值不会改变,在动画结束后我们切换到其他动画的时候,其他动画开始播放时的GameObject的位置会回到这个动画播放前的位置,所以如果我们需要对某个动画应用Root Motion的话,那么这个动画在导入的时候就不要烘焙其在X和Z轴方向上的Root Transform Position,让Unity自行根据动画中根节点的位移进行位移计算GameObject的位置信息;
  3. 注意Root Motion与Rigidbody.Velocity属性的关系,如果有两个动画A和B,播放A动画的时候,希望A动画应用Root Motion,而在播放B动画的时候不想应用Root Motion,那么就直接在切换到动画B的时候,将Animator的applyRootMotion设置为False就OK了。但是如果播放动画的GameObject带有Rigidbody组件,那么需要注意一点,在播放A动画时Rigidbody的Velocity并不会在切换到B动画时清零,也就是说如果A动画的运动速度较快,那么切换到B动画的时候,如果希望B动画播放的时候GameObject按照自己的设定轨迹运动,就需要自行手动在切换到B动画之前将Rigidbody的Velocity属性清零,防止GameObject按照A动画的运动惯性继续运动。这个问题在没有Rigidbody组件的GameObject上不会存在;

这边再岔开一下,说说这个动画跟Rigidbody之间的关系:

  1. 如果我们没有将Root Transform Position的Y和XZ轴进行烘焙的话,那么在动画播放的过程中,Rigidbody将会自动获得动画中物体运动的速度信息,直接通过Rigidbody.Velocity属性就可以获得;
  2. 如果我们将Y轴进行烘焙,那么Rigidbody.Velocity在Y轴上的值将会一直为0,对于XZ轴也是一样的,如果烘焙了XZ轴的位移,那么整个动画播放过程中,Rigidbody.Velocity在X和Z轴上的值都会为0;
  3. 如果播放动画的物体没有Rigidbody组件,那么动画的运动都会仅仅按照动画实际的位移来进行逐帧播放,不会出现上文中提到的动画播放切换之后还存在的运动惯性问题,因为物理引擎依赖于Rigidbody组件,如果没有该组件,所有动画的播放都只是逐帧播放动画,不会存在速度的概念只有移动位移。
  4. Rigidbody使用使用重力对于动画在Y轴上的位移没有任何影响,不论是否对Root Transform Position的Y轴进行了烘焙。

Unity3D中useGravity和isKinematic以及applyRootMotion的备忘

在最近几天的开发中,碰到一个比较有意思(恶心)的问题,需要达到的效果如下:

  1. 有一个NPC,在受到攻击时,会播放一个从站立姿势到击飞浮空的动画;
  2. 在NPC切换击飞浮空动画的同时,NPC需要同时向斜上方飞行,飞行到指定高度待机。

通常我们有几个方案可以选择:

  1. 通过Unity3D自带的物理引擎模拟,给NPC一个斜上方的速度或者作用力,这个通过Rigidbody.velocity设置一个速度向量或者通过Rigidbody.AddFore/Rigidbody.AddRelativeForce进行设置就OK了;
  2. 自己根据需要的速度自行计算每一帧NPC的位移,然后通过直接设置NPC对象Transform.position属性来达成NPC斜向上飞的效果。

这两个方案从理论上来说肯定都是OK的,其实方案一最终在Unity3D内部的PhysicX引擎中的实现也是通过对Transform的position进行设置来完成的,只是额外还会进行碰撞监测和阻力计算等等。

实际开发的过程中,我分别使用了这两种方案来进行测试,对比之后发现:

  1. 方案一模拟效果确实更真实,而且省去了很多的计算过程,但是简单的设置速度和作用力可能无法达到策划设计的受击效果,因为我们制作的是格斗游戏,对于受击对象最终浮空的位置有相对严格的要求,需要为各种连续攻击留足设计空间,这个就不展开说了。总之,如果完全交给物理引擎来演算模拟的话,会出现一些不可控的结果,而那是我们不想要的,所以需要通过一些其他的手段来进行反算,使得我们设置了某些参数之后,物理引擎模拟最终的结果就是我们想要的(最终我就是这么做的);
  2. 方案二简单,而且结果是完全可控的,NPC飞行的轨迹完全是可以预计的,但是由于直接控制Transform的position是完全无视在整个游戏场景中的物理碰撞的,在做position设置之前每次都需要自行检测NPC是否会与场景中可碰撞对象之间的关系,这个性能的开销如何控制以及如何做到有效的检测对于目前的我来说是一个较难的课题,所以最终我放弃了。

那么按照方案一来执行的过程中,又碰到了什么有意思的问题呢?

NPC在受击之后,通过Animator设置了一个Trigger,触发NPC从站立到击飞浮空的动画切换,同时给NPC对象的Rigidbody一个Velocity,代码如下:

[code lang=”csharp”]
mAnimator.SetTrigger ("KickFloat");
rigidbody.velocity = velocity; // NPC斜向上方飞行的初速度
[/code]

可是最终执行的效果是NPC只会往上垂直飞行,在水平方向上根本就没有速度,这是为啥呢?就在我快要把头发拽下来的刹那,我决定先去泡一杯劣质的越南『中原G6』速溶咖啡,等我把咖啡泡好了回到座位上,突然意识到其实在很早之前碰到过另一个问题,当时就是因为Animator的applyRootMotion变量给惹的祸,卧槽啊~

于是,我很精灵的在刚刚的代码基础上加上了一个前缀处理,代码如下:

[code lang=”csharp”]
mAnimator.applyRootMotion = false;
mAnimator.SetTrigger ("KickFloat");
rigidbody.velocity = velocity; // NPC斜向上方飞行的初速度
[/code]

尼玛这下清净了吧,哥哥我半个下午的时间就是在不断的怀疑人生和自我啊,不过看在最终还是搞定的份上,我也就不再说啥了。然后NPC在做从站立过渡到空中浮空待机的动作的同时,终于按照我设定的速度的方向移动了,如释重负啊。

说了这么老些题外话(什么?这些竟然都是题外话?尼玛这已经很丰富了喂,可以做单独一篇文章了喂,那你到底要说的是啥啊?),我的目的只有一个,我就是想说明一下我为啥要针对这三个变量来说道说道,因为它们真的蛮有意思的(恶心了我这个Unity3D傻逼)。为了让自己以后不再傻逼,所以还是记录一下,做个备忘吧。

Rigidbody.isKinematic,这个属性用于控制物理引擎效果是否会在生效,如果这个属性为True,那么所有通过物理引擎给物体施加外力和设置速度等等就不会再有任何效果了。例如如果想做一个让NPC受击浮空之后,在空中停留的效果,就可以通过设置这个变量来实现,只要该变量为true,物理的重力效果就不会生效,NPC并不会自动往下做自由落体运动,而会在空中保持之前的动画状态,位置也不会发生改变。

Rigidbody.useGravity,这个属性就是用来控制物理引擎模拟时是否加入重力影响,例如我们在做击飞效果的时候,通常会通过给受击对象一个击飞后方向的作用力或者运动速度,如果需要考虑重力因素的话,可能会更真实一些,但是有的某些情况下,我们可能不想因为重力的作用对受击对象的飞行轨迹产生影响,那么就可以通过将该变量设置为false就可以关闭重力在屋里引擎模拟过程中对物体整体运动速度和轨迹的影响了。

Animator.applyRootMotion,这个属性是用来控制物体在播放骨骼动画的时候是否应用骨骼根节点的运动参数,也就是说在动画播放的过程中是否使用动画中物体的位移。在实际制作的过程中,总会有某些动画自身带位移,而某些动画是不带位移的,对于那些动画中自带位移的,那么就可以通过设置这个变量为true就可以让对象在播放动画的过程中按照动画中的位移进行移动,这样动画的效果也会更逼真一些而不是通过程序来控制对象的位移(程序大部分情况下都是简单的做匀速或者加速运动,无法做到完全与动画中动作移动幅度完全吻合,可能会出现一些失真的情况)。但是如果一旦设置了这个变量为true,那么请一定注意,这个会对物理引擎在模拟对象的运动轨迹时产生直接的影响,例如在某个动画A中,对象只向Y轴方向进行了移动,在X和Z轴是静止的,那么我们在播放A动画的时候,如果使用Rigidbody设置速度或者施加外力,还是不会让物体在X和Z轴上发生位移的。这是因为,在整个动画播放的过程中(例如0.5秒),Animator会根据动画中物体的位移信息对物体的速度进行赋值,这样达到使用骨骼根节点的位移的效果,也就是说,我在播放动画过程中的任意时间给物体设置了X或者Z轴方向上的运动速度,后续的动画播放帧中,速度又会被Animator强制赋值为跟动画文件中的位移信息一致。

综上,咱们来说说碰到哪些情况,我们需要检查哪些变量?

  1. 如果出现物体无论设置运动速度还是施加作用力,愣是没有任何效果,赶紧检查一下isKinematic变量是否为true,如果是的话,那么肯定是不会有效果的,当然也不会自由落体了;
  2. 如果出现物体往某个一直运动,根本停不下来,那很有可能是useGravity这个变量为false,如果真的不想要这个重力效果,那么设置为false刚好可以从此摆脱地心引力了;
  3. 如果出现物体无论设置运动速度还是施加作用力,而且也没有设置isKinematic变量为true,那么先别怀疑人生,赶紧确认该物体的Animator组件是否设置了applyRootMotion变量,否则头发揪没了也无济于事啊。

关于Mecanim动画系统中,如何让物体应用Generic动画中的位移,可以移步这里查看。

Unity3D编辑器通过拖拽获取文件路径

Unity3D项目开发过程中咱们难免会碰到一些需要设置Prefab路径的时候,例如某个攻击动作的特效,虽然我们最终都是通过填表来完成,如果完全手动填表那实在让人崩溃啊,碰到有的特效命名不小心填错了,那就更加无聊了,所以作为程序猿的我们肯定是要通过更加友好的方式来获取各种目录下各种文件的路径了。

我们早已经习惯了在各种地方通过拖拽来进行文件路径设置了,那么显然拖拽是非常简单又人性化的设定啊。假设我们现在有一个技能设置的编辑器,如下图:

技能编辑器

特效路径就是我们想设置的,而且可能会在平时做一些修改,每次都自己手动输入显然不是我们想要的,那么怎么让这个TextField支持将Project视图下的文件拖拽到这个文本输入框中,并且让它自动获取我拖拽的文件的路径呢?

『技能编辑器』这整个Window其实就是通过各种小控件给拼起来的,Unity3D中默认所有控件都是支持拖拽事件的,所以我们不需要做任何设置,直接针对我们的需求开始就好了。

我们想要的是,特效路径这个Label后边跟着的这个可输入的TextField文本输入框可以接受拖拽文件的方式来进行路径的输入。那么我们需要做的就是在这个『技能编辑器』的Window中监听鼠标拖拽的事情,同时判断鼠标拖拽时鼠标的位置是否处于这个可输入的TextField文本输入框之中就OK了,上代码。
[code lang=”csharp”]
EditorGUILayout.BeginHorizontal ();
EditorGUILayout.LabelField ("特效路径", GUILayout.Width (80));
// 获得特效路径文本输入框的位置和大小参数,用于后续判断鼠标在文本输入框内使用
Rect sfxPathRect = EditorGUILayout.GetControlRect(GUILayout.Width (250));
// 用刚刚获取的文本输入框的位置和大小参数,创建一个文本输入框,用于输入特效路径
string sfxPathText = EditorGUI.TextField (sfxPathRect, meleeAttackSection.sfxPath);
// 判断当前鼠标正拖拽某对象或者在拖拽的过程中松开了鼠标按键
// 同时还需要判断拖拽时鼠标所在位置处于文本输入框内
if ((Event.current.type == EventType.DragUpdated
|| Event.current.type == EventType.DragExited)
&& sfxPathRect.Contains (Event.current.mousePosition)) {
// 判断是否拖拽了文件
if (DragAndDrop.paths != null && DragAndDrop.paths.Length > 0) {
string sfxPath = DragAndDrop.paths [0];
// 拖拽的过程中,松开鼠标之后,拖拽操作结束,此时就可以使用获得的sfxPath变量了
if (!string.IsNullOrEmpty (sfxPath) && Event.current.type == EventType.DragExited) {
DragAndDrop.AcceptDrag ();
// 好了,这下想用这个sfxPath变量干嘛就干嘛吧
}
}
}
EditorGUILayout.EndHorizontal ();
[/code]