标签归档:Unity3D

Unity3D插件之Behavior Designer Movement Pack For Apex Path的巨坑

在目前的项目中,我们有使用到一个叫Apex Path的插件来做自动寻路的事情,这个插件整体来说还是棒棒哒,完全对得起$65的价格,我在写这篇文章的时候,刚好这货又在做半价促销了,链接在此

今天先不展开聊这个Apex Path插件了,今天主要是要吐槽一下Behavior Designer插件的增强包Movement Pack,在初次接触到Behavior Designer插件之时,顿时觉得这货能帮忙解决AI行为树的大部分问题,立马买回来尝试了几把,发现真心不错,可以解决很多问题。然后在他们网站上发现还有一个额外的用于处理移动的增强包,果断再次入手。拿回来简单地测试了一番,感觉还不错。但是在后续的开发中,总是会发现一些奇怪的问题,大体的表现就是NPC完全在没有移动到应该移动到的攻击位置就开始攻击了以及类似的问题,而且这个问题是在有多个使用了Behavior Designer控制的NPC出现在场景中的时候才会出现。

这个真心奇了怪了,作为一个朝内程序猿,我一直都奔着崇洋媚外的态度来看待国外程序员大大们,一直认为他们好牛逼好牛逼,然后先入为主地认为是自己用得肯定有问题,多次检查了自己的代码,依然没能找到问题。最终我就只能搞了一个空场景,就放了两个NPC,让NPC做最简单的行为,把Log加上,尼玛最后知道真相的我,眼泪掉下来啊,有木有!

我们来看看Behavior Designer Movement Pack For Apex Path中Patrol脚本中关于Apex中UnitNavigationEventMessage回调的处理。

 // Add the waypoint back on the stack when the destination is reached
public override void Handle(UnitNavigationEventMessage message)
{
    switch (message.eventCode) {
        case UnitNavigationEventMessage.Event.WaypointReached:
            movableAgent.MoveTo(waypoints[waypointIndex].position, true);
            waypointIndex = (waypointIndex + 1) % waypoints.Length;
        break;
    }
}

然后我们对比一下Apex Path插件中自带的一个PatrolBehaviour脚本是如何处理这个UnitNavigationEventMessage的回调的。

        
void IHandleMessage.Handle(UnitNavigationEventMessage message)
{
    if (message.entity != this.gameObject || message.isHandled)
    {
        return;
    }

    if (message.eventCode == UnitNavigationEventMessage.Event.WaypointReached)
    {
        message.isHandled = true;
        MoveNext(true);
    } else if (message.eventCode == UnitNavigationEventMessage.Event.DestinationReached)
    {
        message.isHandled = true;
        StartCoroutine(DelayedMove());
    }
}

细心对比一下,我们就会发现,在后者的处理逻辑中,加入了一个关于这个消息的entity对象是否为当前脚本所绑定的GameObject对象的判断,如果当前接收到的消息不是当前这个GameObject发出的,是不做任何逻辑,直接return的。然后再回过头来看看Behavior Designer Movement Pack For Apex Path中的处理,你就知道问题出在哪儿了。

因为Apex Path的GameServices中的MessageBus实现机制是任何对象都可以通过GameServices.messageBus访问到这个全局静态的MessageBus对象,并且可以通过subscribe和unsubscribe方法来进行消息的订阅和取消订阅。也就因为这个任何MessageBus的全局机制,任意注册了的对象都会收到回调,所以我们需要在处理消息回调的时候自行判断这个消息是否是我们需要的,或者说需要判断这个消息是否是发给自己的。MessageBus的机制就是一个广播,只要有任何一个对象触发了事件,注册了消息的所有对象都会收到广播,所以需要收到广播消息的对象自行甄别这货是否是发给自己的。

Behavior Designer Movement Pack For Apex Path脚本中对于回调的处理显然是忽略了这个甄别的过程,所以就会出现一些完全不在意料之中的事情,一切都能解释得通了。

Opsive的这个团队在Behavior Designer的工作确实非常惊艳,让人竖大拇哥,但是在这种问题上犯错误只能让我觉得这是实习生做的,或者就是完全没有真正地使用过Apex Path,然后就直接推出了一个似是而非的Movement增强包,我是很不以为然的。今天看到这个Movement的插件还升级了,Update需要花费$10,鉴于这个问题,我想暂时还是不升级了,虽然我很想看看他们升级的版本中是否已经修复了这个低级错误,等我今天闲下来了,我得给他们发个邮件问问。


给Opsive团队发过邮件了,邮件回复说是新的版本中已经加入这个判断了,问题已经修复,回复内容如下:

Hello,

Thank you for letting me know. Have you imported the most recent version of the Apex Path integration off of the integrations page? This integration doesn’t include an ApexPathSteeringBase file. It only includes ApexPathMovement as the base class. Within that class there is a check for the GameObject:

        public virtual void Handle(UnitNavigationEventMessage message)

        {

            if (!message.entity.Equals(gameObject)) {

                return;

            }

            switch (message.eventCode) {

                case UnitNavigationEventMessage.Event.DestinationReached:

                    arrived = true;

                    break;

            }

        }

I am not setting message.isHandled to true because all I am doing when destination is reached is setting a flag so a new destination hasn’t been set yet.

Thank you,

Justin

Unity3D Mecanim动画AnimatorTransitionInfo和AnimatorStateInfo在角色移动和待机平滑切换中的应用

在之前的开发过程中一直仅仅使用到了AnimatorStateInfo这货,平时在做一些判断的时候还特意加入一个判断!Animator.IsInTransition(0)来确定当前这个Animator没有在进行动画过渡,可是这几天同事们总是反应游戏中主角移动起步很慢和停止移动的时候会出现滑步的情况,这个不能忍,听得我很是汗颜啊。

好吧,汗颜的事情就先不表了,我们来看看这个可能是神马问题吧。通常我们游戏当中,角色都会有一个待机的动作,跑步和行走都会有相应地动作,而不同的动作之间的切换,Unity3D会自行做动画融合,这样主角从待机动作切换到跑步的动作时,就不会出现一帧直接切换导致看起来非常机械和卡顿的问题,看起来整个过程会是非常平滑的,这个就是我们要谈到的重点了。

在之前的实现中,我在主角的移动控制脚本PlayerMovement中使用了一段这样的代码来判断当前女主角已经成功的从Idle状态切换到Locomotion状态了

AnimatorStateInfo animInfo = animator.GetCurrentAnimatorStateInfo (0);
if (animInfo.isName ("Locomotion")) {
    // 这里控制角色的位置移动
}

然后在控制角色位置移动的代码段中,我们根据需要进行计算获得女主角在不同坐标上的移动位置,然后将位移作用到角色上。

但是这样做会有什么问题呢?

  • 首先,当角色在从Idle切换到Locomotion动画的过渡中时,上面的这段代码会直接忽略动画过渡的这段时间,所以在女主角从静止起步到跑步的过程中,Animator实际上一直都会保持为Idle的状态,直到整个Transition完成了,Animator的State才会切换到Locomotion,这样的话女主角实际上是在原地播放了这个过渡的动画,而这段时间的动画中,女主角的脚步会从静止切换到小碎步,再到大步跑,而这个时候女主角的位置不会发生变化(没有在女主角跑动动画对应的方向上移动),最终的结果就是主角看起来反应非常慢,起步的时候会出现卡顿,要在原地跑一段时间之后才会移动,这显然是不能接受的。
  • 其次,当角色从Locomotion切换到Idle动画的过渡中时,依然会出现逻辑被忽略的情况,也就是角色实际上已经在从Locomotion切换到Idle了,角色的脚步动作越来越小了,但是因为Animator的设计是在切换过程中,State的名字不会改变,会保持为状态切换之前的状态名,也就是在动画完全切换到Idle之前,State的名字一直都是Locomotion,所以主角在这个时间里头,逻辑会让主角继续运动(因为它满足上面的逻辑,所以还会计算位移,并应用到角色对象上),最终的结果就是看起来主角的跑步动画已经停止但是身体还在动画方向上做位移,出现滑步了,尼玛啊。

既然已经找到问题了,那么我们就肯定有办法来解决它。既然我们知道了动画在切换的过程中可以通过AnimatorTransitionInfo来获取过渡的信息,那么就有办法了,首先我们能确定Idle到Locomotion和Locomotion到Idle的nameHash值,通过比对这两个值就能明确知道当前Animator是从哪个状态切换到哪个状态了,然后根据AnimatorTransitionInfo.normalizedTime可以获取到过渡的进度信息,这样一来我们就能准确的计算出来动画过渡的过程中,角色应有的运动速度了,例如从Idle切换到Locomotion是从速度0到速度4m/s,那么在Idle切换到Locomotion的过程中通过Mathf.Lerp (04normalizedTime)就可以获取实时速度了。最终代码如下:

int hashIdle2Locomotion = Animator.StringToHash ("Base Layer.Idle -> Base Layer.Locomotion");
int hashLocomotion2Idle = Animator.StringToHash ("Base Layer.Locomotion -> Base Layer.Idle");
float moveSpeed = 4f;
float speed = 0f;    
if (animator.IsInTransition (0)) {
    AnimatorTransitionInfo transitionInfo = animator.GetAnimatorTransitionInfo (0);
    float normalizedTime = transitionInfo.normalizedTime;
    if (transitionInfo.nameHash == hashIdle2Locomotion) {
        speed = Mathf.Lerp (0, moveSpeed, normalizedTime);
    } else if (transitionInfo.nameHash == hashLocomotion2Idle) {
        speed = Mathf.Lerp (moveSpeed, 0, normalizedTime);
    }
} else if (animInfo.IsName ("Locomotion")) {
    speed = moveSpeed;
} else if (animInfo.IsName ("Idle")) {
    speed = 0f;
}

Unity3D中使用位运算判断LayerMask是否匹配

在Unity3D开发的过程中,偶尔会碰到需要对物体的Layer进行匹配运算,例如针对不同Layer的GameObject我们可能有不同的处理逻辑。
这个时候能使用位运算来完成这个匹配的话,肯定比我们写一堆的==显得更有逼格啊。那么具体要怎么做呢?

是否直接通过GameObject.layer获取到Layer的值,然后直接跟LayerMask进行位运算呢?不是。因为我们通过GameObject.layer获取到的Layer的值,是我们通过Unity3D Editor中的Edit -> Project Setting -> Tags and Layers 进行Layer设置时候的Layer的数值,这是从0开始依次递增的连续的值,这个显然不适合我们用来做位运算。

实际上我们在通过GameObject.layer获取到Layer值之后,需要先进行移位操作,然后再进行位运算『与』,示例代码如下:

bool IsInLayerMask(GameObject obj, LayerMask layerMask)
{
    // 根据Layer数值进行移位获得用于运算的Mask值
    int objLayerMask = (1 << obj.layer);     
    return (layerMask.value & objLayerMask) > 0;
}

AnimationUtility.GetAnimationEvents获取的动画片段信息中time和SerializedObject对象中保存的time字段信息的区别

今天的工作中原本是需要做一个小工具来批量给某些动画加上一个事件,也就是AnimationEvent这货。查了一下官方文档,也Google了一番,最终在这里找到一个比较符合我需求的解决方案,方案是由Unity Technologies的童鞋提供的,看上去还是蛮靠谱的,传送地址在这里

我也就果断拿过来用了,当然前提还是我们自己需要做一番修改的,因为我的需求是只需要在原有的AnimationClip已有的AnimationEvent列表的最后面添加一个OnSkillEnd的AnimationEvent,所以我需要先获取到已有的AnimationEvent信息列表,这里我就想着直接用AnimationUtility.GetAnimationEvents方法来搞定了,而且我看到前面提到的Unity Technologies的童鞋也是这么做的,那么我也就想着这么搞应该就OK了。

不过事实证明这样还是有问题的,因为通过AnimationUtility.GetAnimationEvents获得的AnimationEvent信息列表中得所有AnimationEvent实例中的time字段确实是触发时间,也就是动画播放的具体时间,是绝对时间。而Unity实际保存到动画FBX文件对应的meta文件中的time字段是相对动画播放进度,可以认为是相对时间。

当我们使用文本方式打开动画FBX的meta文件时,我们会发现在这个YAML文件中有一个events节点,这个节点下面保存的就都是这个AnimationClip上绑定的所有AnimationEvent信息,我们找到time字段看一下就会发现,所有Event节点中的time字段都小于等于1.0,然后我们再通过AnimationUtility.GetAnimationEvents方法将AnimationClip中的AnimationEvent信息读取出来,我们会发现,这个AnimationEvent中time实际上就是等于meta文件中的time字段(动画播放进度百分比)乘以AnimationClip的完整时长。

我们可以看一段YAML文件:

events:
      - time: .0572792366
        functionName: OnSkillTrigger
        data: a_ke_zhan_attack_1#0
        objectReferenceParameter: {instanceID: 0}
        floatParameter: 0
        intParameter: 0
        messageOptions: 0
      - time: .143198088
        functionName: OnSkillTrigger
        data: a_ke_zhan_attack_1#1
        objectReferenceParameter: {instanceID: 0}
        floatParameter: 0
        intParameter: 0
        messageOptions: 0
      - time: .393794745
        functionName: OnSkillTrigger
        data: a_ke_zhan_attack_1#2
        objectReferenceParameter: {instanceID: 0}
        floatParameter: 0
        intParameter: 0
        messageOptions: 0
      - time: .744630039
        functionName: OnSkillTrigger
        data: a_ke_zhan_attack_1#3
        objectReferenceParameter: {instanceID: 0}
        floatParameter: 0
        intParameter: 0
        messageOptions: 0

所以为了避免出现使用实际时间来作为相对时间保存,我们可以选择自行换算或者直接采用同一个标准进行计算,例如我们读取的时候采用直接读取SerializedObject对象中的SerializedProperty来获取time字段,保存的时候也通过SerializedObject对象中SerializedProperty来设置新的time字段就可以了。

NGUI中Panel切换Camera之后丢失尺寸大小的问题

最近在项目中碰到一个比较诡异的问题,让自己着实头疼了一阵子。

具体问题是这样的:

游戏中使用PoolManager将使用过的所有界面Panel进行了缓存,在需要的时候从Pool中Spawn。

而为了避免每个Pool中所有的Prefab实例在切换场景时被自动Destory掉,我选择了将这部分通用的弹出式的UI所储存的Pool设置为dontDestroyOnLoad,这样一来这些界面就会一直存在内存里头,省去了来回内存释放又再次请求的过程,只需要每次从Pool里头Spawn就可以了。

当PanelA在Scene_1中显示过一次,那么PanelA就已经缓存到UIPool中了,当我们切换到Scene_2时,UIPool并不会自动Destory掉。那么这个时候如果我们需要显示PanelA,我们只需要调用SpawnPool的Spawn方法就可以直接把PanelA显示出来就好了。问题来了,这个时候显示的PanelA的尺寸完全不对,原本应该与屏幕宽度完全匹配的PanelA变成了宽度只有2个像素了。

我们这么辛苦地搞了一个自己认为可以节省内存重复申请和释放过程的机制,肯定不能因为碰到这个问题就放弃了对吧。那就开始找问题吧,那么为什么这个PanelA的宽度会最终变成了2呢?

首先,我们先来看看这个Panel的GameObject层级图:

UIPanel

|– UISprite(使用Anchors进行背景图尺寸和位置的设置)

|– UISprite

通过Unity3D Editor中,我们发现通过Anchors来进行尺寸和位置设置的背景UISprite的尺寸和位置相对于UIPanel这个根节点确实没有出错,Left Anchor对应UIPanel的Left相对距离为0,Right Anchor对应UIPanel的Right相对距离为0,Top Anchor对应UIPanel的Bottom的相对距离为100,Bottom Anchor对应UIPanel的Bottom相对距离为0。也就是说背景UISprite的高度还是正确的,但是由于整个UIPanel的尺寸出错了,所以导致了背景UISprite的尺寸和相对位置出现了错误。

那么怎么解决这个问题呢?首先我们可以确认UISprite是通过Anchors来设置尺寸和位置,我们可以在NGUI中找到对应的代码,这段代码在UIWidget.cs文件中的OnAnchor()方法中,这个方法中会获取Anchor设置中相对物体的边界信息,在我们这个案例中这个相对物体就是UIPanel了。UIPanel.cs中通过GetSides()方法来获取UIPanel的尺寸和位置信息,而这个最终的计算会依赖于UIPanel所处Layer的Camera。

这下问题就明朗了,因为我们将UIPanel在场景A中创建并显示出来,之后当UIPanel不再显示的时候将其缓存起来了,但是当我们再次从其他场景切换到场景A中时,我们又再次通过Spawn方法将UIPanel显示出来。此时UIPanel依然会调用其继承自UIRect的GetSlides方法来获取边界信息。

public virtual Vector3[] GetSides (Transform relativeTo)
{
if (anchorCamera != null) return mCam.GetSides(cameraRayDistance, relativeTo);

Vector3 pos = cachedTransform.position;
for (int i = 0; i &lt; 4; ++i)
mSides[i] = pos;

if (relativeTo != null)
{
for (int i = 0; i &lt; 4; ++i)
mSides[i] = relativeTo.InverseTransformPoint(mSides[i]);
}
return mSides;
}

而此时anchorCamera已经为空了,因为我们切换场景的时候,UIPanel首次Spawn出来时自动选择的UI层的Camera对象已经被Destroy了, 而现在我们重新将UIPanel显示出来的时候,UIPanel并不会自动重新选择当前场景中UI层的Camera对象,所以这里就需要我们手动来设置一下UIPanel的Camera。

仔细看了一下UIPanel初始化的时候是如何设置Anchors的,然后发现了有ResetAnchors、UpdateAnchors和ResetAndUpdateAnchors和ResetAndUpdateAnchors就是我们想要找的货了。也就是说我们在当场景切换成功之后,应该手动将SpawnPool中缓存的Panels对象调用一遍ResetAndUpdateAnchors方法,这样一来就不会再出现因为丢失Camera对象,而导致UIPanel尺寸不正确的问题了。我们只需要在OnLevelWasLoaded的回调函数中这些处理一下就好了:

void OnLevelWasLoaded (int level)
{
Debug.Log ("OnLevelWasLoaded: " + level);
foreach (KeyValuePair&lt;string, PrefabPool&gt; pair in _uiSpawnPool.prefabPools) {
PrefabPool pool = pair.Value;
foreach (Transform xform in pool.spawned) {
UIPanel panel = xform.GetComponent ();
if (panel != null) {
panel.ResetAndUpdateAnchors ();
}
}
foreach (Transform xform in pool.despawned) {
UIPanel panel = xform.GetComponent ();
if (panel != null) {
panel.ResetAndUpdateAnchors ();
}
}
}
}