<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
  <channel>
    <title>LPQ-Blog</title>
    <description>liu_peiqi@163.com</description>
    <link>http://www.lpq.design/</link>
    <atom:link href="http://www.lpq.design/feed.xml" rel="self" type="application/rss+xml"/>
    <pubDate>Wed, 15 May 2024 08:18:53 +0000</pubDate>
    <lastBuildDate>Wed, 15 May 2024 08:18:53 +0000</lastBuildDate>
    <generator>Jekyll v3.9.5</generator>
    
      <item>
        <title>C++多态库Proxy学习笔记（一）</title>
        <description>&lt;p&gt;本文是C++20标准下微软提供实现多态能力的头文件库的学习笔记。当前网络上虽然有很多相关文章了，但是本文侧重点除了学习库怎么用以外，还深入探讨Proxy库用到的C++14、C++17、C++20等的特性，学习Proxy是怎么实现的。&lt;/p&gt;

&lt;!--more--&gt;

&lt;h2 id=&quot;常规c的多态实现&quot;&gt;常规C++的多态实现&lt;/h2&gt;

&lt;p&gt;实现容器基类，然后派生动态数组和集合类型：&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-CPP&quot;&gt;class Container{
public:
    virtual void insert(...);
    virtual void remove(...);
    virtual ~Container();
}

class Vector:Container{
public:
    virtual void insert(...) override;
    virtual void remove(...) override;
    virtual ~Vector();
}

class Set:Container{
public:
    virtual void insert(...) override;
    virtual void remove(...) override;
    virtual ~Set();
}
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;在使用时，我们可以就针对Container操作就可以了。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-CPP&quot;&gt;void do_some_thing(Container* c){
    c-&amp;gt;insert(...);
    if(...){
        c-&amp;gt;remove(...);
    }
}

int main(){
    Vector v;
    Set s;
    do_smoe_thing(&amp;amp;v);
    do_smoe_thing(&amp;amp;s);
    return 0;
}
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;CPP这个是很简单的例子。之前我还长时间维护过芯片行业的电路仿真引擎，它最早是上个世纪七八十年代伯克利大学设计的C语言项目。项目巨大，维护复杂，在一众面向对象语言出现前，面向对象和多态的思想已经在C语言中开始尝试了。它是这么处理多态的：&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-CPP&quot;&gt;struct Container{
    void (*insert_ptr)(...);
    void (*remove_ptr)(...);
}

struct Vector{
    void (*insert_ptr)(...);
    void (*remove_ptr)(...);
    void insert(...);
    void remove(...);
    void init(){
        insert_ptr = insert;
        remove_ptr = remove;
    }
}

struct Set{
    Container c;
    void insert(...);
    void remove(...);
    void init(){
        c.insert_ptr = insert;
        c.remove_ptr = remove;
    }
}

void do_some_thing(Container* c){
    c-&amp;gt;insert(...);
    if(...){
        c-&amp;gt;remove(...)
    }
}

int main(){
    Vector v;
    v.init();
    Set s;
    s.init();
    do_some_thing((Container*)(&amp;amp;v));
    do_some_thing(&amp;amp;s.c);
    return 0;
}
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;上面在实现Vector和Set的时候分别提供了两种写法，原因是在上述引擎代码中都有体现，它通过C语言明确的内存布局，利用强制类型转换和函数指针实现多态能力。当然这么实现太灵活、太危险，稍稍大意就会写错漏写，或者成员函数顺序错误。&lt;/p&gt;

&lt;h2 id=&quot;cpp模板&quot;&gt;CPP模板&lt;/h2&gt;

&lt;p&gt;上面东拉西扯一些入门知识，主要是铺垫多态实现的思路，现在很多人发出了思想挑战：多态不一定要通过虚函数、虚表来实现。比如CPP的模板编程也可以：&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-CPP&quot;&gt;template&amp;lt;class Container&amp;gt;
void do_something(Container&amp;amp; c){
    c.insert(...);
    if(...){
        c.remove(...);
    }
}

#include&amp;lt;vector&amp;gt;
#include&amp;lt;set&amp;gt;

int main(){
    std::vector&amp;lt;int&amp;gt; v;
    std::set&amp;lt;int&amp;gt; s;
    do_something(v);
    do_something(s);
    return 0;
}
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;上面代码编译无法通过，主要是表达思路，编译器生成多个重载函数，给两次调用实现了两种不同的操作，静态的实现了多态逻辑。但是这个还是不够的，很多时候我们需要多态不仅仅是静态的，还需要动态的，它们还要是统一的类型，要能放到一个数组中的。模板编程也不是不行：&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-CPP&quot;&gt;#include &amp;lt;functional&amp;gt;
struct ContainerFacade{
    std::function&amp;lt;void(...)&amp;gt; insert;
    std::function&amp;lt;void(...)&amp;gt; remove;
}

void do_something(std::vector&amp;lt;ContainerFacade&amp;gt; containers){
    for(auto c : containers){
        c.insert(...);
        if(...){
            c.remove(...);
        }
    }
}

#include &amp;lt;vector&amp;gt;
#include &amp;lt;set&amp;gt;

int main(){
    std::vector&amp;lt;int&amp;gt; v;
    std::set&amp;lt;int&amp;gt; s;
    do_something({ContainerFacade{std::bind(&amp;amp;std::vector&amp;lt;int&amp;gt;::emplace_back, 
                                            &amp;amp;v, std::placeholder::_1),
                                  std::bind(&amp;amp;std::vector&amp;lt;int&amp;gt;::remove, 
                                            &amp;amp;v, std::placeholder::_1)},
                  ContainerFacade{std::bind(&amp;amp;std::set&amp;lt;int&amp;gt;::insert,
                                            &amp;amp;v, std::placeholder::_1),
                                  std::bind(&amp;amp;std::set&amp;lt;int&amp;gt;::remove,
                                            &amp;amp;v, std::placeholder::_1)}});
    return 0;
}
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;利用CPP现代语言特性和语法糖，std::funcation和std::bind动态绑定，实现运行时多态。诚如网友的评价： “代码丑到这个样子，性能一定快到飞起吧！”。&lt;/p&gt;

&lt;p&gt;其实这个实现跟第一节第二个例子，C语言的多态实现是一个思路。我们要的多态，不一定就是个继承来的对象，而是方便的统一的抽象，可以放到一起，调用相同逻辑或类似逻辑的具体函数就行。C语言的实现太过灵活，抹除了类型型别，在实践过程中对使用者的要求太过苛刻。传统的继承方法常常讨论&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;is a&lt;/code&gt;关系，&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Cat is a Animal&lt;/code&gt;，&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Dog is a Animal&lt;/code&gt;极为繁琐不说，在工程实践中，某个类通常是很多基类的子类。CPP可以多继承，甚至处理菱形继承还搞出了虚继承。其他语言则很多直接禁止了多继承，再构造出接口的概念，最终实现的还是多继承。使用时&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;dynamic cast&lt;/code&gt;类型转换漫天飞不说，如果已经设计好的库的类型不满足新的接口逻辑，那么还要再通过派生和继承新接口逻辑来调整，最后产生更多的隐晦的类型。&lt;/p&gt;

&lt;p&gt;既然如此，那为什么不放弃继承关系，回到原始需求，类型只要具备特定接口的能力，能被统一的方法调用，是不是就好了？&lt;/p&gt;

&lt;p&gt;非侵入式的运行时多态，外观和接口只是特定几个类型中的某几个函数的聚合。&lt;/p&gt;

&lt;p&gt;上述CPP模板的案例符合这些特征，但是过程太过复杂繁琐，微软的&lt;a href=&quot;https://github.com/microsoft/proxy&quot;&gt;Proxy&lt;/a&gt;库则提供了相对简单的实现。&lt;/p&gt;

&lt;h2 id=&quot;proxy&quot;&gt;Proxy&lt;/h2&gt;

&lt;pre&gt;&lt;code class=&quot;language-CPP&quot;&gt;#include &amp;lt;proxy/proxy.h&amp;gt;

struct insert : pro::dispatch&amp;lt;void(...)&amp;gt;{
    template &amp;lt;class T&amp;gt;
    void operator()(T&amp;amp; self, ...){self.insert(...);}
};

struct ContainerFacade : pro::facade&amp;lt;insert&amp;gt;{};

void do_something(pro::proxy&amp;lt;ContainerFacade&amp;gt; c){
    c.invoke&amp;lt;insert&amp;gt;(...);
}

#include &amp;lt;set&amp;gt;

int main(){
    std::vector&amp;lt;int&amp;gt; v;
    do_something(pro::make_proxy&amp;lt;ContainerFacade&amp;gt;(v));
}
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;当然后面还有简化版，通过宏来代替上面一长串外观和仿函数定义，调用处也简单一点，不用那个invoke了：&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-CPP&quot;&gt;#include &amp;lt;proxy/proxy.h&amp;gt;

namespace spec {
    PRO_DEF_MEMBER_DISPATCH(insert, void(...));
    PRO_DEF_FACADE(ContainerFacade, insert);
}

void do_something(pro::proxy&amp;lt;spec::ContainerFacade&amp;gt; c){
    c.insert(...);
}

#include &amp;lt;set&amp;gt;

int main(){
    std::set&amp;lt;int&amp;gt; v;
    do_something(&amp;amp;v);
}
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;“代码丑到这个样子，性能一定快到飞起吧！”。&lt;/p&gt;
</description>
        <pubDate>Wed, 15 May 2024 00:00:00 +0000</pubDate>
        <link>http://www.lpq.design/2024/05/Cpp-proxy/</link>
        <guid isPermaLink="true">http://www.lpq.design/2024/05/Cpp-proxy/</guid>
        
        <category>C++</category>
        
        
      </item>
    
      <item>
        <title>Lyra Vs. ALS</title>
        <description>&lt;p&gt;ALS是UE4及之前版本流传极广的一款动画设计方案和模板，其设计优秀到被几乎除Epic外大部分重度射击、动作游戏项目引用。
Lyra是UE5新推出的TPS射击游戏小样，其中使用的动画设计方案不仅全方位使用了新引擎的动画技术细节，更是在运行性能和设计解耦方面有着非常出色的指导。
本文主要分析和对比一下两者之间的差异。&lt;/p&gt;

&lt;!--more--&gt;

&lt;h2 id=&quot;als的持械设计叠加动画与混合ovelay&quot;&gt;ALS的持械设计——叠加动画与混合（Ovelay）&lt;/h2&gt;

&lt;p&gt;ALS的重点是如何使用最少的基础移动动作来混合出多种多样风格化的持械动作。
这个侧重点带来的优点有：&lt;/p&gt;
&lt;ol&gt;
  &lt;li&gt;需要的动作量少，内存占用小，动作美术工作少。一套基础移动动作加n个持械姿势序列，以及必要的持械优化动作。&lt;/li&gt;
  &lt;li&gt;不同持械动作切换流畅。同一个蓝图内切换不同持械状态姿势，可以针对性设计设置多样化的过渡时间和表现。&lt;/li&gt;
  &lt;li&gt;上下半身分离的叠加设计方式更方便做出符合基础移动的运动倾斜和呼吸节律。&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;同样这个侧重点带来的缺点有：&lt;/p&gt;
&lt;ol&gt;
  &lt;li&gt;运行时开销巨大。主线程方面Update逻辑繁重，工作线程上动作混合和动态叠加节点量大，计算开销大。&lt;/li&gt;
  &lt;li&gt;动作姿态上同质化严重，持械风格差异小。因为ALS只使用了一套基础的移动动作，其他持械表现都是通过动态叠加表达的，所以各个持械移动风格必然相似。尽管为了表达不同枪械的厚重，ALS设计了角色盆骨偏移，但它的表现力上限很低。&lt;/li&gt;
  &lt;li&gt;上一条也产生了新的问题：角色盆骨偏移后，基础移动动作的腿脚不能精准表达原动画设计的幅度和位置。ALS通过脚本IK的机制来“掩盖”这个问题，但这样做仍然不能达到优秀的表现。&lt;/li&gt;
  &lt;li&gt;随着角色持械复杂度的提升，叠加层的状态切换数量也线性增加，后续的开发维护和复用性不断降低。&lt;/li&gt;
  &lt;li&gt;ALS原作者对UE动画技术应用的炉火纯青，但是普通项目里的动作美术很难理解ALS中抽象的姿势混合和曲线如何实现具体的表现，这带来了项目内比较大的制作成本。（虽然我也赞同人菜就多学的观点）&lt;/li&gt;
&lt;/ol&gt;

&lt;h2 id=&quot;lyra的动作设计通过链接新的动画层来动态替换动画资源&quot;&gt;Lyra的动作设计——通过链接新的动画层来动态替换动画资源&lt;/h2&gt;

&lt;p&gt;Lyra的重点是如何设计运行效率高，开发阶段高度解耦的动作方案，同时可以兼顾精细的动作表现。
这个侧重点带来的优点：&lt;/p&gt;
&lt;ol&gt;
  &lt;li&gt;非常高的并行化和高效的运行效率。新的引擎有更强大的工具，因而在底层思路上就有更开阔的方向可以选择。&lt;/li&gt;
  &lt;li&gt;极致的资源解耦。主动画蓝图里没有具体的动画资源，所有涉及到最终的表现都来源于动态链接的新动画层。&lt;/li&gt;
  &lt;li&gt;极致的解耦意味着可以多人共同开发。&lt;/li&gt;
  &lt;li&gt;灵活的动画层具备更丰富的动作细节表现可以通过换动画层来实现不同持械状态的风格化呈现。&lt;/li&gt;
  &lt;li&gt;可以使用最传统的美术动作设计和开发模式，制作方案可繁可简。&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Lyra的缺点：&lt;/p&gt;
&lt;ol&gt;
  &lt;li&gt;动画实例多，如果不加以控制对动画更新逻辑和动画事件的设计，则会拖慢运行效率。&lt;/li&gt;
  &lt;li&gt;抽象程度高，如果对整体动画没有概览性认知，那么就很难理解具体的局部。&lt;/li&gt;
  &lt;li&gt;依赖的动画资源多，美术制作量大，程序内存消耗多。&lt;/li&gt;
&lt;/ol&gt;

&lt;h2 id=&quot;强强联合&quot;&gt;强强联合&lt;/h2&gt;

&lt;p&gt;ALS和Lyra都是两套成功的动画方案，但是它俩又不能逐条逐项的对比优劣，因为它们各自的侧重点和想解决的方向不同，关公不好战秦琼。那么最容易想到的就是能不能把它俩结合起来，把每个方案的优点都拿到手。既要极致性能和解耦又要少的动画资源和使用少量的内存空间。
如果不加思考的糅合两个方案，比如在ALS的基础上直接把叠加层或者基础移动给换成可以连接的动画层，这种做法不说能继承多少优点，但至少把每种方案的缺点都继承了：&lt;/p&gt;
&lt;ol&gt;
  &lt;li&gt;ALS的设计方案和混合方案就决定了它需要主线程计算的压力特别大，就算用UE5新的节点重构ALS，那也是隔靴搔痒。在ALS的基础上再引入多个动态链接的动画层则会更加重它的能耗。&lt;/li&gt;
  &lt;li&gt;ALS的叠加层动画本身是非常小的，如果单纯的把这块作为动态链接的动画层来实现，那么得到的除了能安慰自己可以“解耦”持械状态以外，再没有任何运行内存上和开发上的增益。&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;基于以上约束条件，不如把ALS的核心思想拿来，在Lyra的方案中应用，听起来这似乎是一筐萝卜和萝卜一筐的差异，但事实上这是主次之辩。我们如果只使用ALS里的思想混合叠加出各个持械状态的基础移动动作，而不引入其它ALS的逻辑，来追求更少的资源量“生成”不同持械下的风格化动作，再嵌入到Lyra中，那么就能获取到解耦和资源量少的优势。当然，它依然比原生的方案增加了一些工作线程上的动画混合开销，好在，因为解耦所以后面还可以替换更合适的基础动作。&lt;/p&gt;

&lt;p&gt;总的来说，各种方案都有优缺点，在参考学习时还是要先学习思路为主，不能光参考形式。&lt;/p&gt;
</description>
        <pubDate>Mon, 01 Apr 2024 00:00:00 +0000</pubDate>
        <link>http://www.lpq.design/2024/04/LyraVsALS/</link>
        <guid isPermaLink="true">http://www.lpq.design/2024/04/LyraVsALS/</guid>
        
        <category>UE</category>
        
        <category>Animation</category>
        
        
      </item>
    
      <item>
        <title>提升URO优化时的动画表现</title>
        <description>&lt;p&gt;URO是一个常用的减少动画开销的优化方法，但是它会降低具体的动画表现。本文提供一种主线程增加极少开销的动画表现增强方案。&lt;/p&gt;

&lt;!--more--&gt;

&lt;h2 id=&quot;uro的细节&quot;&gt;URO的细节&lt;/h2&gt;

&lt;h3 id=&quot;如何设置uro&quot;&gt;如何设置URO&lt;/h3&gt;
&lt;p&gt;URO全称&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Update Rate Optimizations&lt;/code&gt;，是引擎根据当前模型的屏占比来适当跳过动画解算帧的技术方案。它在&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;SkinnedMeshComponent&lt;/code&gt;组件上&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Optimization&lt;/code&gt;分组中，当设置为&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;True&lt;/code&gt;后&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;SkinnedMeshComponent&lt;/code&gt;可以通过全局函数&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;AnimUpdateRateTick&lt;/code&gt;、&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;AnimUpdateRateSetParams&lt;/code&gt;获取当前动画需要跳过的帧数。值得一提的是&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;AlwaysTickPose&lt;/code&gt;等更新参数是&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;URO&lt;/code&gt;的前置控制，而不是反过来。&lt;/p&gt;

&lt;h3 id=&quot;档位影响&quot;&gt;档位影响&lt;/h3&gt;
&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;FAnimUpdateRateParameters&lt;/code&gt;类型&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;AActor::UpdateRateParameters::bShouldUseLodMap&lt;/code&gt;字段可以设置跳帧依据是Lod级别还是屏幕高的平方，默认是&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;False&lt;/code&gt;，但是也没有开放对外设置，即默认使用屏幕高的平方分级。&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;FSkeletalMeshObject::UpdateMinDesiredLODLevel&lt;/code&gt;函数中&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ComputeBoundsScreenRediusSquared&lt;/code&gt;计算得到的具体参数。&lt;/p&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;AActor::UpdateRateParameters::BaseVisibleDistanceFactorThesholds&lt;/code&gt;里设置具体大于多高后选择当前跳应该跳几帧（参数为数组，&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Index+1&lt;/code&gt;为&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;DesiredEvaluationRate&lt;/code&gt;）。&lt;/p&gt;

&lt;h3 id=&quot;执行细节&quot;&gt;执行细节&lt;/h3&gt;
&lt;p&gt;具体到每次&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;USkinnedMeshComponent::TickPose&lt;/code&gt;会更新具体的&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;UpdateCounter&lt;/code&gt;和&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;EvaluateCounter&lt;/code&gt;等变量来确定当前帧是否要执行动画更新。&lt;/p&gt;

&lt;p&gt;因为很多人第一次在了解到&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;URO&lt;/code&gt;跳帧这个概念时会觉得被跳的过程没有动画更新，所以最后会觉得表现会有卡顿。事实上UE已经用插值的方法处理了这样的卡顿。在启用&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;URO&lt;/code&gt;后&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;USkeletalMeshComponent::RefreshBoneTransforms&lt;/code&gt;阶段会在&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;USkeletalMeshComponent::PostAnimEvaluation&lt;/code&gt;函数中对骨骼数组进行插值（平滑）。&lt;/p&gt;

&lt;p&gt;因为上述插值机制的存在，所以有些时候会发现某些骨骼动画在启用跳帧后反而变得流畅了。举个例子，门的开门关门两个姿态切换时忘记或者错误的设置过渡时间为0，那么在30FPS的帧率执行下启用&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;URO&lt;/code&gt;就可能看到门的开关过渡过程，而关闭&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;URO&lt;/code&gt;或者在极高帧率下就看不到开关过渡表现。&lt;/p&gt;

&lt;p&gt;&lt;em&gt;插值是当启用了跳帧后每次都执行的，而不是只有被跳帧才执行。&lt;/em&gt;&lt;/p&gt;

&lt;h3 id=&quot;ue留下的伏笔&quot;&gt;UE留下的伏笔&lt;/h3&gt;
&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;USkeletalMeshComponent::TickPose&lt;/code&gt;中当发现当前帧要跳过时，会调用虚函数&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;UAnimInstance::OnUROSkipTickAnimation&lt;/code&gt;。&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;USkeletalMeshComponent::PostAnimEvaluation&lt;/code&gt;中在对骨骼插值前会调用虚函数&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;UAnimInstance::OnUROPreInterpolation_AnyThread&lt;/code&gt;。&lt;/p&gt;

&lt;p&gt;从函数后缀可以看出，&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;OnUROSkipTickAnimation&lt;/code&gt;是主线程执行，&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;OnUROPreInterpolation_AnyThread&lt;/code&gt;可能在其他线程执行。&lt;/p&gt;

&lt;h2 id=&quot;uro降低了动画表现&quot;&gt;URO降低了动画表现&lt;/h2&gt;
&lt;p&gt;因为跳帧，所以动画细节减少也是必然的。这个时候目标在屏幕占比很低，因而好像也没有什么问题。但是在一些情况下，这个问题又影响较大。&lt;/p&gt;

&lt;h3 id=&quot;旋转&quot;&gt;旋转&lt;/h3&gt;
&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;TPS&lt;/code&gt;/&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;FPS&lt;/code&gt;类别下游戏大部分敌人或者队友都是在举枪瞄准目标的。如果动画中又设计了上下半身分离，下半身转身通过相关动画来控制，上半身通过旋转（&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;AnimNode-ModifyBone&lt;/code&gt;）腰身（&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Spine01-Spine02-Spine03&lt;/code&gt;）面对目标，那么这时候跳帧就会让角色表现出旋转卡顿或者延迟。&lt;/p&gt;

&lt;p&gt;下图角色持枪突然向右偏转很大角度又回正就是出现的旋转延迟，移动已经旋转了角色，但是角色的上半身没有及时扭腰重新瞄准对象。&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;http://www.lpq.design/images/ue_uro_skip_opt/UROAim.gif&quot; alt=&quot;Rotate&quot; /&gt;&lt;/p&gt;

&lt;h3 id=&quot;蒙太奇&quot;&gt;蒙太奇&lt;/h3&gt;
&lt;p&gt;一些受击动画通常通过蒙太奇播放，它们的特点是相对来说短，15帧左右，而且最大动作幅度又聚集在前几帧。这个是时候跳帧就容易看不到具体的表现。&lt;/p&gt;

&lt;h2 id=&quot;增加动画蓝图以增强表现&quot;&gt;增加动画蓝图以增强表现&lt;/h2&gt;
&lt;p&gt;要提高因跳帧而降低了的动画表现，最简单的办法就是关掉&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;URO&lt;/code&gt;或者减少跳帧频率。但是更激进的做法是，如果能在跳帧时执行一个简化的动画蓝图，那么不就能提供更好的解决方案么。&lt;/p&gt;

&lt;h3 id=&quot;类似post-animblueprint的方案&quot;&gt;类似Post AnimBlueprint的方案&lt;/h3&gt;
&lt;p&gt;执行另一个动画蓝图，跟这个需求最接近的就是&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Post AnimBlueprint&lt;/code&gt;，它在常规动画蓝图执行完以后用当前动画姿态作为输入再执行一些额外的节点。这些节点通常会是一些物理节点或者&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;IK&lt;/code&gt;节点。&lt;/p&gt;

&lt;p&gt;最开始的设想是利用这个后处理蓝图，那能不能让常规动画蓝图走&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;URO&lt;/code&gt;流程，然后后处理蓝图全速不跳帧的执行。&lt;/p&gt;

&lt;p&gt;调查之后发现存在的主要困难是：&lt;/p&gt;
&lt;ol&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;USkeletalMeshComponent&lt;/code&gt;中对&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;AnimInstance&lt;/code&gt;和&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;PostProcessAnimIntance&lt;/code&gt;调用位置都是一样的，只是在顺序上先调用执行&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;AnimInstance&lt;/code&gt;，然后再调用执行&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;PostProcessAnimInstance&lt;/code&gt;。如果单独改造后处理蓝图，那么对源码的修改工程量大，对原有调用逻辑和执行关系破坏较深。&lt;/li&gt;
  &lt;li&gt;构造一个类似&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;PostProcessAnimInstance&lt;/code&gt;的，专用于跳帧时执行的动画蓝图，避免对原有后处理逻辑的影响。这样其实和上面的困难差不多，还是要自己维护一套跟跟现有逻辑相差很大的动画蓝图执行逻辑。&lt;/li&gt;
  &lt;li&gt;更主要的困难是新增的这个蓝图需要的动画参数通常在之前的常规蓝图里，两边需要仔细设计好访问属性，即便这样做好了也免不了要在&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;UpdateAnimation&lt;/code&gt;阶段的类型转换和属性访问开销，这个操作一般会在主线程里。&lt;/li&gt;
&lt;/ol&gt;

&lt;h3 id=&quot;copyposemesh-master-slave方案&quot;&gt;CopyPose/Mesh Master Slave方案&lt;/h3&gt;
&lt;p&gt;目的还是为了构造一个全速&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Tick&lt;/code&gt;但是执行的动画节点数量极少的动画蓝图，而包含原来动画逻辑的蓝图还是跳帧降频执行。因此再额外创建一个&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;MeshComponent&lt;/code&gt;假设它叫&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;MasterMesh&lt;/code&gt;，他的动作主要输入是从原动画蓝图&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CopyPose&lt;/code&gt;来的，不能跳过的蒙太奇要只能出现在&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;MesterMesh&lt;/code&gt;上的动画蓝图中，否则对于叠加蒙太奇动画则会出问题（重复叠加）。&lt;/p&gt;

&lt;p&gt;这个方案另一个直观的影响就是我们本来是要降开销的，但是这样又引入一个全速组件，又多了额外的全套动画开销，尽管它简化了，但是花在&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;SkeletalMeshComponent&lt;/code&gt;还是少不了。&lt;/p&gt;

&lt;h3 id=&quot;不跳过蒙太奇的方案&quot;&gt;不跳过蒙太奇的方案&lt;/h3&gt;
&lt;p&gt;如果不考虑上文说过的转身，单纯考虑处理不跳过蒙太奇的话还是很方便的。在&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;SkinnedMeshComponent::ShouldTickiAnimation&lt;/code&gt;中就可以加入针对特殊蒙太奇处理的内容。这样每当上述举例的受击蒙太奇播放时，忽略跳过逻辑，保证正常的受击动画表现。&lt;/p&gt;

&lt;p&gt;它的问题是战斗中的怪物大概率常常处于受击中，动画跳帧逻辑相当于没有，开销还是会显著增加。&lt;/p&gt;

&lt;h2 id=&quot;改造ue动画调用&quot;&gt;改造UE动画调用&lt;/h2&gt;
&lt;p&gt;依据UE本身存在的虚函数&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;UAnimInstance::OnUROSkipTickAnimation&lt;/code&gt;和&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;UAnimInstance::OnUROPreInterpolation_AnyThread&lt;/code&gt;，大胆推测UE本身曾经也面临这个问题，并像通过这两个函数来增强在跳帧时的动画表现。&lt;/p&gt;

&lt;h3 id=&quot;跳帧时的动画输入&quot;&gt;跳帧时的动画输入&lt;/h3&gt;
&lt;p&gt;动画蓝图的输入源头一般分为两种，一种是直接的动画资源（&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;AnimSequence&lt;/code&gt;），一种是其他动作的复制（&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CopyPose/PoseSnapShot&lt;/code&gt;），动画蓝图的输出是骨骼位置数组（&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;TArray&amp;lt;FTransform&amp;gt; BoneSpaceTransforms&lt;/code&gt;），中间过程依赖的数据是曲线（&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;FBlendedHeapCurve AnimCurves&lt;/code&gt;）和属性（&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;FHeapCustomAttributes CustomAttributes&lt;/code&gt;）。&lt;/p&gt;

&lt;p&gt;如果跳帧时要想省略动画资源的解算，那么最好的办法就是把上一帧的骨骼位置数组存下来。&lt;/p&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;USkeletalMeshComponent&lt;/code&gt;组件中有两个骨骼数组，&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;BoneSpaceTransforms&lt;/code&gt;和&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CachedBoneSpaceTransforms&lt;/code&gt;。它俩的区别是，&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;BoneSpaceTransforms&lt;/code&gt;是最后渲染前使用骨骼位置，&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CachedBoneSpaceTransforms&lt;/code&gt;是启用跳帧后，动画蓝图输出的结果，跳帧时通过插值方法输出到&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;BoneSpaceTransforms&lt;/code&gt;。如果使用&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;BoneSpaceTransforms&lt;/code&gt;那么还要再加入一个新函数&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;OnUROPostInterpolation_AnyThread&lt;/code&gt;在插值后调用并修改&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;BoneSpaceTransforms&lt;/code&gt;。如果使用&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CachedBoneSpaceTransforms&lt;/code&gt;，那么在&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;OnUROPreInterpolation_AnyThread&lt;/code&gt;函数修改就行，随后的插值过程会将结果最后输入到&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;BoneSpaceTransforms&lt;/code&gt;中的。&lt;/p&gt;

&lt;h3 id=&quot;引入新的动画图表&quot;&gt;引入新的动画图表&lt;/h3&gt;
&lt;p&gt;至此，我们就可以实时对骨骼数组进行修改了，最简单的方案就是根据&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;BoneIndex&lt;/code&gt;直接修改骨骼位置和旋转，但是这样扩展性就极为有限了，在比较明确且极限的方案可以采用，以得到极高的性能表现。&lt;/p&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;IAnimClassInterface::GetAnimBlueprintFunctions&lt;/code&gt;可以得到每个动画蓝图类定义的各个图表（&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Graph&lt;/code&gt;），动画蓝图的默认图表（&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;AnimGraph&lt;/code&gt;）位于这个数组的首位，其他新增的动画层（&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;AnimLayer&lt;/code&gt;）依次添加在随后。因此我们可以定义一个专门用于跳帧阶段执行的动画层来灵活的修改具体骨骼，而存储的当帧骨骼数组就可以作为这个动画层的输入姿势（&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;InputPose&lt;/code&gt;）。相比于其他正常的动画层可以通过连接操作自动调用，我们特化的动画层则需要主动处理。
派生类必要的成员声明：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-CPP&quot;&gt;class UAnimInstanceOpt : UAnimInstance{
    FName OnSkipAnimLayerName                  = TEXT(&quot;OnUROSkipAnimLayer&quot;);
    FAnimNode_Base* OnSkipLayerRootNode        = nullptr;
    FAnimNode_LinkedInputPose* OnSkipInputPose = nullptr;
}
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;新动画层和该层的输入姿势节点初始化（&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Initialize&lt;/code&gt;）&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-CPP&quot;&gt;void UAnimInstanceOpt::NativeInitializeAnimation(){
    auto* AnimBPClass = IAnimClassInterface::GetFromClass(GetClass());
    for(const auto&amp;amp; Layer : AnimBPClass-&amp;gt;GetAnimBlueprintFunctions()){
        if(Layer.Name != OnSkipAnimLayerName){ continue; }
        auto* RootNode      = Layer-&amp;gt;OutputPoseNodeProperty;
        OnSkipLayerRootNode = RootNode-&amp;gt;ContainerPtrToValuePtr&amp;lt;FAnimNoode_Root&amp;gt;(this);
        auto&amp;amp; Proxy         = GetProxyOnGameThread&amp;lt;FAnimInstanceProxy&amp;gt;();
        Proxy.InitializeRootNode_WithRoot(OnSkipLayerRootNode);

        auto InputPoseName = FAnimNode_LinkedInputPose::DefaultInputPoseName;
        for(auto Index = 0; Index &amp;lt; Layer.InputPoseNames.Num(); ++Inddex){
            if(Layer.InputPoseName[Index] != InputPoseName){ continue; }
            auto* InputProperty = Layer.InputPoseNodeProperties[Index];
            OnSkipInputPose     = InputProperty-&amp;gt;ContainerPtrToValuePtr&amp;lt;FAnimNode_LinkedInputPose&amp;gt;(this);
            break;
        }
        break;
    }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;主线程执行的更新准备，复制相关属性到代理类以方便工作线程访问：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-CPP&quot;&gt;void UAnimInstanceOpt::OnUROSkipTickAnimation(){
    PreUpdateAnimation(0.0f);
    auto&amp;amp; Proxy  = GetProxyOnAnyThread&amp;lt;FAnimInstanceProxy&amp;gt;();
    auto* AnimBP = Proxy.GetAnimClassInterface();
    Proxy.InitializeObjects(this);
    PropertyAccess::ProcessCopies(Proxy.GetAnimInstanceObject(),
                                  AnimBP-&amp;gt;GetPropertyAccessLibrary(),
                                  EPropertyAccessCopyBatch::ExternalBatched);
}
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;新动画层的核心更新（&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Update&lt;/code&gt;）和解算（&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Evaluate&lt;/code&gt;）过程，以及输出结果到&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CacheBoneSpaceTransforms&lt;/code&gt;中：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-CPP&quot;&gt;void UAnimInstanceOpt::OnUROPreInterpolation_AnyThread(...){
    if (bInTickAnimation){
        OriginBoneSpaceTransforms = CacheBoneSpaceTransforms；
        return;
    }
    auto&amp;amp; Proxy = GetProxyOnAnyThread&amp;lt;FAnimInstanceProxy&amp;gt;();
    Proxy.ForceCachedBones();
    FMemMark Mark(FMemStack::Get());
    // initialize nodes
    OnSkipLayerRootNode-&amp;gt;Initialize_AnyThread(FAnimationInitializeContext(&amp;amp;Proxy));
    // update nodes
    FAnimationUdpateSharedContext SharedContext;
    FAnimationUpdateContext Context(&amp;amp;Proxy, Proxy.GetDeltaSeconds(), &amp;amp;SharedContext);
    Proxy.UdpateAnimation_WithRoot(Coontext, OnSkipLayerRootNode, OnSkipAnimLayerName);
    // fill input pose data.
    OnSkipInputPose-&amp;gt;CachedInputPose.CopyBonesFrom(OriginBoneSpaceTransforms);
    // evaluate nodes.
    FPoseContext EvaluationContext(&amp;amp;Proxy);
    EvaluationContext.ResetToRefPose();
    Proxy.EvaluateAnimation_WithRoot(EvaluationContext, OnSkipLayerRootNode);
    // fill CacheBoneSpaceTransforms
    EvaluationContext.Pose.CopyBonesTo(CacheBoneSpaceTransforms);
}
&lt;/code&gt;&lt;/pre&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Anim Layer&lt;/th&gt;
      &lt;th&gt;Detail&lt;/th&gt;
      &lt;th&gt;InputPose&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;img src=&quot;http://www.lpq.design/images/ue_uro_skip_opt/SkipAnimLayer.PNG&quot; alt=&quot;OnSkipAnimLayer&quot; /&gt;&lt;/td&gt;
      &lt;td&gt;&lt;img src=&quot;http://www.lpq.design/images/ue_uro_skip_opt/SkipAnimLayerDetail.PNG&quot; alt=&quot;Detail&quot; /&gt;&lt;/td&gt;
      &lt;td&gt;&lt;img src=&quot;http://www.lpq.design/images/ue_uro_skip_opt/SkipAnimLayerInput.PNG&quot; alt=&quot;input&quot; /&gt;&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;h2 id=&quot;补偿新动画图表引入的问题&quot;&gt;补偿新动画图表引入的问题&lt;/h2&gt;
&lt;h3 id=&quot;如何旋转&quot;&gt;如何旋转&lt;/h3&gt;
&lt;p&gt;回到最初想解决的问题：在跳帧时依然可以修改特定骨骼以减少敌人面向的抖动。新增了动画层以后直接调用原有的&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ModifyBone&lt;/code&gt;依然不能解决问题：&lt;/p&gt;
&lt;ol&gt;
  &lt;li&gt;最初的旋转是通过计算目标和自身的角度差值，再&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;AddToExisting&lt;/code&gt;调整腰椎角度以面向目标。因为原始动画资源作为输入时是没有偏转的，所以再加上当前差值就是目标角度。当使用了新的动画层以后，跳帧时再执行到&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ModifyBone&lt;/code&gt;时，动画的输入姿势已经有上一帧加过的偏转差值了，再加新的差值就转过头了。&lt;/li&gt;
  &lt;li&gt;如果直接计算&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;WorldSpace&lt;/code&gt;下的旋转，使用&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ReplaceExisting&lt;/code&gt;模式，那么就不仅要记录之前一次跳帧时已经转了多少，还要记录本身动画中原骨骼本身的旋转。计算太过复杂，不好实现。&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;可以解决的办法有：&lt;/p&gt;
&lt;ol&gt;
  &lt;li&gt;还是使用&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;AddToExisting&lt;/code&gt;，每帧记录当前额外旋转的角度，下一次跳帧计算时再减去记录值就能得到正确的结果。&lt;/li&gt;
  &lt;li&gt;新写个动画节点，它能像IK一样直接把腰椎转向特定的世界坐标下的位置。&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;这里使用上述的第二个方案，原因一个是记录每次增加值的数量要跟修改的腰椎数相关，实现出来略麻烦，灵活性低。另一个是本来目的就是要减少跳帧过程的性能开销，第二个方案直接在代码里一次计算还能节省一些节点求解的开销。具体计算过程就不展开了，就是单纯IK类角度计算。&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;http://www.lpq.design/images/ue_uro_skip_opt/RotateSpines.PNG&quot; alt=&quot;RotateSpine&quot; /&gt;&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th style=&quot;text-align: center&quot;&gt;URO&lt;/th&gt;
      &lt;th style=&quot;text-align: center&quot;&gt;FullTick&lt;/th&gt;
      &lt;th style=&quot;text-align: center&quot;&gt;UROOpt BoneSpaceTransforms&lt;/th&gt;
      &lt;th style=&quot;text-align: center&quot;&gt;UROOpt CachedBone&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td style=&quot;text-align: center&quot;&gt;&lt;img src=&quot;http://www.lpq.design/images/ue_uro_skip_opt/UROAim.gif&quot; alt=&quot;URO&quot; /&gt;&lt;/td&gt;
      &lt;td style=&quot;text-align: center&quot;&gt;&lt;img src=&quot;http://www.lpq.design/images/ue_uro_skip_opt/FullTickAim.gif&quot; alt=&quot;FullTick&quot; /&gt;&lt;/td&gt;
      &lt;td style=&quot;text-align: center&quot;&gt;&lt;img src=&quot;http://www.lpq.design/images/ue_uro_skip_opt/UROOptBoneAim.gif&quot; alt=&quot;UROOptBoneSpace&quot; /&gt;&lt;/td&gt;
      &lt;td style=&quot;text-align: center&quot;&gt;&lt;img src=&quot;http://www.lpq.design/images/ue_uro_skip_opt/UROOptCachedAim.gif&quot; alt=&quot;UROOptCachedBone&quot; /&gt;&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;h3 id=&quot;播放蒙太奇&quot;&gt;播放蒙太奇&lt;/h3&gt;
&lt;p&gt;跟普通的动画序列资源不同，蒙太奇（&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Montage&lt;/code&gt;）动画的播放和数据是记录在动画实例类中（&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;UAnimInstance&lt;/code&gt;），只有具体的动画槽节点执行到是才需要从动画实例代理（&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;FAnimInstanceProxy&lt;/code&gt;）中获取具体姿势。为了在新动画层中加入槽节点以及和常规更新过程中无缝衔接蒙太奇，需要在跳帧时主动调用蒙太奇相关更新接口：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-CPP&quot;&gt;class UAnimInstanceOpt : UAnimInstance{
    void OnUROSkipTickAnimation(float DeltaSeconds){
        PreUpdateAnimation(0.0f);
        // initialize obj...
        UpdateMontage(DeltaSeconds);
        UpdateMontageSyncGroup();
        UpdateMontageEvaluationData();
        // copy property access..
    }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;这加入蒙太奇的相关接口调用后，还有的问题：发现蒙太奇2倍速播放了。其原因是跳帧时和正常更新时都会推进蒙太奇的进度条。解决这个问题就要对正常更新动画的&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;DeltaSeconds&lt;/code&gt;进行修正：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-CPP&quot;&gt;void UAnimInstanceOpt::Montage_UpdateWeight(float DeltaSeconds) {
    if (!bInUROTickAnim &amp;amp;&amp;amp; UROSkipElapsedTime &amp;gt; 0) {
        DeltaSeconds = FMath::Max(DeltaSeconds - UROSkipElapsedTime, 0.0f);
    }
    Super::Montage_UpdateWeight(DeltaSeconds);
}
void UAnimInstanceOpt::Montage_Advance(float DeltaSeconds) {
    if (!bInUROTickAnim &amp;amp;&amp;amp; UROSkipElapsedTime &amp;gt; 0) {
        DeltaSeconds = FMath::Max(DeltaSeconds - UROSkipElapsedTime, 0.0f);
    }
    Super::Montage_Advance(DeltaSeconds);
}

&lt;/code&gt;&lt;/pre&gt;

&lt;h3 id=&quot;叠加蒙太奇动画&quot;&gt;叠加蒙太奇动画&lt;/h3&gt;
&lt;p&gt;上述处理完后只是能播蒙太奇动画，如果蒙太奇资源拥有一个叠加动画轨道，那么最后的动作结果还是会抽动。原因跟旋转那一节问题类似：跳帧时动画层的输入是上一帧的动作结果，叠加动画会重复增加。
像修正蒙太奇的&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;DeltaSeconds&lt;/code&gt;一样，如果把上一帧的叠加动画减去了，那么结果就会正确。&lt;/p&gt;

&lt;p&gt;这一次要对&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;FAnimInstanceProxy&lt;/code&gt;修改，新增一个虚函数接口&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;AdjustAdditivePoses&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-CPP&quot;&gt;void FAnimInstanceProxy::SlotEvaluatePose(...){
    // AnimTrack-&amp;gt;GetAnimationPose(...);
    if (AdditivePoses.Num() &amp;gt; 0){
        AdjustAdditivePoses(SlotNodeName, InTotalNodeWeight, AdditivePoses);
        // AccumulateAdditivePose ..
    }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;再从&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;FAnimInstanceProxy&lt;/code&gt;派生一个新子类，来完实现虚函数接口以修正叠加动画姿势：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-CPP&quot;&gt;class FAzureAnimInstanceOptProxy: public FAnimInstanceProxy {
    void AdjustAdditivePoses(const FName&amp;amp; SlotNodeName, float NodeWeight,
                             TArray&amp;lt;FSlotEvaluationPose&amp;gt;&amp;amp; AdditivePoses){
        // assumption AdditivePoses.Num() == 1
        if(bInUROTick){
            FSlotEvaluationPose NewPose(Last.Weight, Last.AdditiveType);
            NewPose.Pose.SetBoneContainer(&amp;amp;AdditivePoses[0].Pose.GetBoneContainer());
            NewPose.Pose.CopyBonesFrom(Last.Pose);
            AdditivePoses.Add(MoveTemp(NewPose));
            return;
        }
        Last.Weight       = NodeWeight;
        Last.AdditiveType = AdditivePoses[0].AdditiveType;
        Last.Pose.Reset(AdditivePoses[0].Pose.GetNumBones());
        for(const auto&amp;amp; Index : AdditivePoses[0].Pose.ForEachBoneIndex()){
            const auto&amp;amp; Bone = AdditivePoses[0].Pose[Index];
            Last.Pose.Emplace(Bone.GetRotation().Inverse(), Bone.GetTranslation() * -1,
                              Bone.GetScale() * -1);
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th style=&quot;text-align: center&quot;&gt;URO&lt;/th&gt;
      &lt;th style=&quot;text-align: center&quot;&gt;FullTick&lt;/th&gt;
      &lt;th style=&quot;text-align: center&quot;&gt;UROOpt&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td style=&quot;text-align: center&quot;&gt;&lt;img src=&quot;http://www.lpq.design/images/ue_uro_skip_opt/URO_clip_2.gif&quot; alt=&quot;URO&quot; /&gt;&lt;/td&gt;
      &lt;td style=&quot;text-align: center&quot;&gt;&lt;img src=&quot;http://www.lpq.design/images/ue_uro_skip_opt/FullTick_clip_2.gif&quot; alt=&quot;FullTick&quot; /&gt;&lt;/td&gt;
      &lt;td style=&quot;text-align: center&quot;&gt;&lt;img src=&quot;http://www.lpq.design/images/ue_uro_skip_opt/UROOpt_clip_2.gif&quot; alt=&quot;UROOpt&quot; /&gt;&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;h2 id=&quot;具体性能开销&quot;&gt;具体性能开销&lt;/h2&gt;
&lt;p&gt;新引入的动画图层又带来了额外的求解过程，所以性能上是介于不跳帧和常规跳帧之间。&lt;/p&gt;

&lt;p&gt;以下系列图中&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;GameThread&lt;/code&gt;代表动画主线程更新耗时，&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Total&lt;/code&gt;代表动画在工作线程解算耗时，尾缀表示跳帧程度，图中表达了跳1、2、4、5帧的情况。&lt;/p&gt;

&lt;p&gt;没有蒙太奇的情况：&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;http://www.lpq.design/images/ue_uro_skip_opt/profiling_normal.PNG&quot; alt=&quot;Normal&quot; /&gt;&lt;/p&gt;

&lt;p&gt;有30%时间播放单个蒙太奇的情况：&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;http://www.lpq.design/images/ue_uro_skip_opt/profiling_30montage.PNG&quot; alt=&quot;30%montage&quot; /&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;以上数据不是定量评定的，只能当作定性分析，30%蒙太奇的消耗是推算出来的，方法是$0.7 * NormalFrame + 0.3 * MontageFrame$.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;具体的火焰图如下：
带蒙太奇的常规动画更新&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;http://www.lpq.design/images/ue_uro_skip_opt/npc_uro_montage_update.PNG&quot; alt=&quot;Update With Montage&quot; /&gt;&lt;/p&gt;

&lt;p&gt;跳帧时的动画更新&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;http://www.lpq.design/images/ue_uro_skip_opt/npc_skip_update.PNG&quot; alt=&quot;URO Update on Skip&quot; /&gt;&lt;/p&gt;

&lt;p&gt;使用新动画层时的动画更新&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;http://www.lpq.design/images/ue_uro_skip_opt/npc_uroopt_montage_update.PNG&quot; alt=&quot;UROOpt Update on Skip&quot; /&gt;&lt;/p&gt;

&lt;p&gt;带蒙太奇的常规动画解算&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;http://www.lpq.design/images/ue_uro_skip_opt/npc_uro_montage_evaluate.PNG&quot; alt=&quot;Evaluate With Montage&quot; /&gt;&lt;/p&gt;

&lt;p&gt;跳帧时的动画解算&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;http://www.lpq.design/images/ue_uro_skip_opt/npc_skip_evaluate.PNG&quot; alt=&quot;URO Evaluate on Skip&quot; /&gt;&lt;/p&gt;

&lt;p&gt;使用新动画层时的动画解算&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;http://www.lpq.design/images/ue_uro_skip_opt/npc_uroopt_evalute.PNG&quot; alt=&quot;UROOpt Evaluate on Skip&quot; /&gt;&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;http://www.lpq.design/images/ue_uro_skip_opt/npc_uroopt_montage_evaluate.PNG&quot; alt=&quot;UROOpt Evaluate on Skip&quot; /&gt;&lt;/p&gt;

&lt;h2 id=&quot;没有解决的问题&quot;&gt;没有解决的问题&lt;/h2&gt;
&lt;h3 id=&quot;新增蓝图的曲线和属性&quot;&gt;新增蓝图的曲线和属性&lt;/h3&gt;
&lt;p&gt;完成上述所有改造后似乎很完美，但是实际仍然没有处理在跳帧时的中间变量：资源中的曲线（&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Curves&lt;/code&gt;）和属性（&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CustomAttributes&lt;/code&gt;）。
没有处理的原因：&lt;/p&gt;
&lt;ol&gt;
  &lt;li&gt;跳帧时的动作输入本来就是上一帧的结果，没有新的来源于资源中的具体数值。&lt;/li&gt;
  &lt;li&gt;如果蒙太奇叠加动画携带曲线，那么还是会出现错误。原因是在&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;AdjustAdditivePoses&lt;/code&gt;中对曲线和属性进行修正太过繁琐而判断适用范围小。&lt;/li&gt;
&lt;/ol&gt;

&lt;h3 id=&quot;步伐平缓&quot;&gt;步伐平缓&lt;/h3&gt;
&lt;p&gt;假设一种情况，虽然在通常下很难见到这么极端的情况：角色跑动步幅频率是一秒5步，动画跳5帧更新（&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;30FPS/s&lt;/code&gt;）。这个条件下动画会因采样失真而完全看不到目标角色的跑步动作。&lt;/p&gt;

&lt;p&gt;设想过一种解决方案是跳帧数不按照固定的帧数，而是动态的从资源中获得。比如在角色的移动动作中加入一个指导跳帧数的曲线，其数值是当前帧可以安全跳过的帧数，当每次动画更新时就把这个数值记录到&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;URO Param&lt;/code&gt;的&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;SkipCounter&lt;/code&gt;上。那什么位置是不能安全跳过的帧呢，比如说脚在空中最高最低处，比如某个动作幅度最大处等等。&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;http://www.lpq.design/images/ue_uro_skip_opt/steps.PNG&quot; alt=&quot;key_steps&quot; /&gt;&lt;/p&gt;

&lt;p&gt;对于多个动作混合的话，一方面需要&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;SyncMark&lt;/code&gt;同步相位；另一方面步幅最好用&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;SpeedWarping&lt;/code&gt;方式处理，单纯的&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;BlendSpace&lt;/code&gt;或者&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;MutiBlend&lt;/code&gt;得到的混合曲线不合适。&lt;/p&gt;

</description>
        <pubDate>Wed, 29 Nov 2023 00:00:00 +0000</pubDate>
        <link>http://www.lpq.design/2023/11/ue_uro_skip_opt/</link>
        <guid isPermaLink="true">http://www.lpq.design/2023/11/ue_uro_skip_opt/</guid>
        
        <category>UE</category>
        
        <category>Animation</category>
        
        <category>URO</category>
        
        
      </item>
    
      <item>
        <title>UE Delegate Bind的冗余复制问题</title>
        <description>&lt;p&gt;起因是发现动画实例在初始化时，创建和绑定本地委托太多，导致耗时太久。&lt;/p&gt;

&lt;!--more--&gt;

&lt;p&gt;查看TDelegate的Bind函数源码，忽略模板信息：&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;void BindLambda(FunctorType&amp;amp;&amp;amp; InFunctor){
    *this = CreateLambda(InFunctor);
}

static TDelegate CreateLambda(FunctorType&amp;amp;&amp;amp; InFunctor){
    TDelegate Result;
    TBaseFunctorDelegateInstance::Create(Result, InFunctor);
    return Result;
}
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;拷贝构造函数和赋值函数同时调用，产生了一些冗余浪费。似乎Bind过程中的赋值操作没上面必要，如果把Bind函数直接改成Create方法，就能减少复制和赋值函数调用。&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;void BindLambda(FunctorType&amp;amp;&amp;amp; InFunctor){
    TBaseFunctorDelegateInstance::Create(*this, InFunctor);
}
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;BindLambda函数统计
|测试|耗时|调用次数|
| :-: | :-: | :-: |
|修改前|0.188ms|190p|
|修改后|0.099ms|190p|&lt;/p&gt;

&lt;p&gt;看起来问题解决了，但实际这一行代码执行了怎样的一个过程呢？&lt;/p&gt;

&lt;h3 id=&quot;具体发生了什么&quot;&gt;具体发生了什么&lt;/h3&gt;

&lt;p&gt;UE Delegate的源码非常复杂，便于理解，我们分三个层次理解：&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;最外层的模板封装，它对外提供统一兼容Lambda、函数指针、UFunction、UObject等类型的绑定和分发操作的接口。&lt;/li&gt;
  &lt;li&gt;标定和判断是否是同一个委托的ID，以及可以提前绑定的函数参数。&lt;/li&gt;
  &lt;li&gt;开辟和析构的空间分配器。&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;这里做一个简化模拟，暂不考虑模板、空间分配器和参数提前绑定的逻辑。&lt;/p&gt;

&lt;p&gt;委托ID实现，用以判断是否是同一委托。UE的实现中ID是原子变量，线程安全但耗费大。&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;class FDelegateHandle {
public:
  enum GenerateNewHandle { New };
  FDelegateHandle(GenerateNewHandle) {
    static long id = 0;
    ID = ++id;
    std::cout &amp;lt;&amp;lt; &quot;FDelegateHandle(GenerateNewHandle):ID(&quot; &amp;lt;&amp;lt; ID
              &amp;lt;&amp;lt; &quot;) by:&quot; &amp;lt;&amp;lt; this &amp;lt;&amp;lt; std::endl;
  }
  FDelegateHandle() : ID(0) {
    std::cout &amp;lt;&amp;lt; &quot;Empty FDelegateHandle():ID(&quot; &amp;lt;&amp;lt; ID &amp;lt;&amp;lt; &quot;) by:&quot; &amp;lt;&amp;lt; this
              &amp;lt;&amp;lt; std::endl;
  }
  long long ID;
};
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;UE原版中用来提供ID和参数绑定的公共基类。这里删除了参数绑定相关功能，并主动实现了复制构造函数，来监控打印实例复制过程。&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;class TCommonDelegateInstance {
public:
  explicit TCommonDelegateInstance()
      : Handle(FDelegateHandle::GenerateNewHandle::New) {
    std::cout &amp;lt;&amp;lt; &quot; TCommonDelegateInstance() by:&quot; &amp;lt;&amp;lt; this &amp;lt;&amp;lt; std::endl;
  }

  TCommonDelegateInstance(TCommonDelegateInstance &amp;amp;other) {
    std::cout
        &amp;lt;&amp;lt; &quot;default copy TCommonDelegateInstance(TCommonDelegateInstance&amp;amp;other) by:&quot;
        &amp;lt;&amp;lt; this &amp;lt;&amp;lt; std::endl;
    Handle.ID = other.Handle.ID;
  }

  FDelegateHandle Handle;
};
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;UE中用来实现各个类型绑定的实现类，这里只摘取了Lambda对应的类作为分析。而且原版Delegate的空间分配是通过默认的分配器管理的，并且使用placement new操作符主动调用相应构造函数。这里调用简化版的默认构造会导致多执行函数，后面涉及到时会指出。&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;class TFunctionDelegateInstance : public TCommonDelegateInstance {
public:
  TFunctionDelegateInstance(const void *InFunctor)
      : TCommonDelegateInstance(), Functor(InFunctor) {
    std::cout &amp;lt;&amp;lt; &quot;TFunctionDelegateInstance(const void *InFunctor) by:&quot; &amp;lt;&amp;lt; this
              &amp;lt;&amp;lt; &quot; InFunctor:&quot; &amp;lt;&amp;lt; InFunctor &amp;lt;&amp;lt; std::endl;
  }

  TFunctionDelegateInstance() : Functor(nullptr) {
    std::cout &amp;lt;&amp;lt; &quot;Alloc TFunctionDelegateInstance() by:&quot; &amp;lt;&amp;lt; this &amp;lt;&amp;lt; std::endl;
  }

  TFunctionDelegateInstance(TFunctionDelegateInstance &amp;amp;Other)
      : TCommonDelegateInstance(Other) {
    std::cout &amp;lt;&amp;lt; &quot;default copy TFunctionDelegateInstance(TFunctionDelegateInstance&amp;amp; &quot;
                 &quot;other)  by:&quot;
              &amp;lt;&amp;lt; this &amp;lt;&amp;lt; std::endl;
    this-&amp;gt;Functor = Other.Functor;
  }

  void CreateCopy(TFunctionDelegateInstance *&amp;amp;Base) {
    std::cout &amp;lt;&amp;lt; &quot;CreateCopy(TCommonDelegateInstance &amp;amp;Base) by:&quot; &amp;lt;&amp;lt; Base
              &amp;lt;&amp;lt; std::endl;
    Base = new TFunctionDelegateInstance();
    new (Base) TFunctionDelegateInstance(*this);
  }

  static void Create(TFunctionDelegateInstance *&amp;amp;Base, const void *InFunctor) {
    std::cout
        &amp;lt;&amp;lt; &quot;Create(TFunctionDelegateInstance &amp;amp;Base, const void *InFunctor) by:&quot;
        &amp;lt;&amp;lt; Base &amp;lt;&amp;lt; std::endl;
    Base = new TFunctionDelegateInstance();
    new (Base) TFunctionDelegateInstance(InFunctor);
  }

protected:
  const void *Functor;
};
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;最终对外封装的委托类型，无参构造函数在原版中几乎无行为。&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;class TDelegate {
public:
  ~TDelegate() { std::cout &amp;lt;&amp;lt; &quot;~TDelegate() by:&quot; &amp;lt;&amp;lt; this &amp;lt;&amp;lt; std::endl; }
  TDelegate() { std::cout &amp;lt;&amp;lt; &quot;Empty TDelegate() by:&quot; &amp;lt;&amp;lt; this &amp;lt;&amp;lt; std::endl; }
  TDelegate(TDelegate &amp;amp;&amp;amp;Other) {
    std::cout &amp;lt;&amp;lt; &quot;TDelegate(TDelegate&amp;amp;&amp;amp; Other) by:&quot; &amp;lt;&amp;lt; this &amp;lt;&amp;lt; std::endl;
    *this = std::move(Other);
  }
  TDelegate(const TDelegate &amp;amp;Other) {
    std::cout &amp;lt;&amp;lt; &quot;TDelegate(const TDelegate&amp;amp; Other) by:&quot; &amp;lt;&amp;lt; this &amp;lt;&amp;lt; std::endl;
    *this = Other;
  }
  TDelegate &amp;amp;operator=(TDelegate &amp;amp;&amp;amp;Other) {
    Other.Instance-&amp;gt;CreateCopy(this-&amp;gt;Instance);
    std::cout &amp;lt;&amp;lt; &quot;operator =(TDelegate&amp;amp;&amp;amp; Other) by:&quot; &amp;lt;&amp;lt; this;
    if (this-&amp;gt;Instance) {
      std::cout &amp;lt;&amp;lt; &quot; ID:&quot; &amp;lt;&amp;lt; this-&amp;gt;Instance-&amp;gt;Handle.ID;
    }
    std::cout &amp;lt;&amp;lt; std::endl;
    return *this;
  }
  TDelegate &amp;amp;operator=(const TDelegate &amp;amp;Other) {
    Other.Instance-&amp;gt;CreateCopy(this-&amp;gt;Instance);
    std::cout &amp;lt;&amp;lt; &quot;operator =(const TDelegate&amp;amp; Other) by:&quot; &amp;lt;&amp;lt; this;
    if (this-&amp;gt;Instance) {
      std::cout &amp;lt;&amp;lt; &quot; ID:&quot; &amp;lt;&amp;lt; this-&amp;gt;Instance-&amp;gt;Handle.ID;
    }
    std::cout &amp;lt;&amp;lt; std::endl;
    return *this;
  }

  void Bind(void *InFunctor) {
    std::cout &amp;lt;&amp;lt; &quot;TDelegate::Bind by:&quot; &amp;lt;&amp;lt; this &amp;lt;&amp;lt; std::endl;
    *this = Create(InFunctor);
  }

  static TDelegate Create(void *InFunctor) {
    TDelegate result;
    std::cout &amp;lt;&amp;lt; &quot;TDelegate::Create by:&quot; &amp;lt;&amp;lt; &amp;amp;result &amp;lt;&amp;lt; std::endl;
    TFunctionDelegateInstance::Create(result.Instance, InFunctor);
    return result;
  }

  void BindNew(void *InFunctor) {
    std::cout &amp;lt;&amp;lt; &quot;TDelegate::BindNew by:&quot; &amp;lt;&amp;lt; this &amp;lt;&amp;lt; std::endl;
    TFunctionDelegateInstance::Create(this-&amp;gt;Instance, InFunctor);
  }

  TFunctionDelegateInstance *Instance;
};

&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;对于一个常规声明和绑定行为：&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;  TDelegate delegate;
  delegate.Bind(func);
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;则输出：&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;Empty TDelegate() by:000000D71F4FFBD8
TDelegate::Bind by:000000D71F4FFBD8
Empty TDelegate() by:000000D71F4FFB68
TDelegate::Create by:000000D71F4FFB68
Create(TFunctionDelegateInstance &amp;amp;Base, const void *InFunctor) by:00007FF620AB2370
// 以下两行打印信息在UE原版中应不存在
//FDelegateHandle(GenerateNewHandle):ID(1) by:0000028B63517860
//TCommonDelegateInstance() by:0000028B63517860
// *** Alloc应对应UE原版分配器的Allocate函数
Alloc TFunctionDelegateInstance() by:0000028B63517860
// ID递增，原子操作
FDelegateHandle(GenerateNewHandle):ID(2) by:0000028B63517860
 TCommonDelegateInstance() by:0000028B63517860
TFunctionDelegateInstance(const void *InFunctor) by:0000028B63517860 InFunctor:00007FF620A71000
CreateCopy(TCommonDelegateInstance &amp;amp;Base) by:0000000000000000
// 以下两行打印信息在UE原版中应不存在
//FDelegateHandle(GenerateNewHandle):ID(3) by:0000028B63517640
//TCommonDelegateInstance() by:0000028B63517640
// *** Alloc应对应UE原版分配器的Allocate函数
Alloc TFunctionDelegateInstance() by:0000028B63517640
// 不触发ID递增行为
Empty FDelegateHandle():ID(0) by:0000028B63517640
default copy TCommonDelegateInstance(TCommonDelegateInstance&amp;amp;other) by:0000028B63517640
default copy TFunctionDelegateInstance(TFunctionDelegateInstance&amp;amp; other)  by:0000028B63517640
operator =(TDelegate&amp;amp;&amp;amp; Other) by:000000D71F4FFBD8 ID:2
~TDelegate() by:000000D71F4FFB68
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;而使用修改过后的函数，则输出：&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;Empty TDelegate() by:000000D71F4FFBC8
TDelegate::BindNew by:000000D71F4FFBC8
Create(TFunctionDelegateInstance &amp;amp;Base, const void *InFunctor) by:00007FF620A77A3D
// 以下两行打印信息在UE原版中应不存在
//FDelegateHandle(GenerateNewHandle):ID(4) by:0000028B635178E0
//TCommonDelegateInstance() by:0000028B635178E0
// *** Alloc应对应UE原版分配器的Allocate函数
Alloc TFunctionDelegateInstance() by:0000028B635178E0
// ID递增，原子操作
FDelegateHandle(GenerateNewHandle):ID(5) by:0000028B635178E0
 TCommonDelegateInstance() by:0000028B635178E0
TFunctionDelegateInstance(const void *InFunctor) by:0000028B635178E0 InFunctor:00007FF620A71000
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;对比可得出新方法少9次函数调用，其中Allocate函数少一次。&lt;/p&gt;

&lt;h3 id=&quot;可能存在的问题&quot;&gt;可能存在的问题&lt;/h3&gt;

&lt;p&gt;Delegate有个行为是只要绑定新的函数上来，那么会先析构旧的函数引用。而新方法里没有显式执行这一行为，那么它会引起问题么？&lt;/p&gt;

&lt;p&gt;在DelegateBase.h的文件中存在placement new的重载&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;void * operator new(size_t Size, FDelegateBase&amp;amp; Base){
    return Base.Allocate((int)Size);
}
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;而FDelegateBase的Allocate的函数中本身就携带对旧委托内容的析构行为：&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;void* Allocate(int32 Size){
    if(auto* CurrentInstance = GetDelegateInstanceProtected()){
        CurrentInstance-&amp;gt;~IDelegateInstance();
    }
    if(DelegateSize != Size){
        DelegateAllocator.ResizeAllocation(0, Size);
        DelegateSize = Size;
    }
    return DelegateAllocator.GetAllocation();
}
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;所以这里不会产生问题。&lt;/p&gt;

&lt;h3 id=&quot;疑惑&quot;&gt;疑惑&lt;/h3&gt;

&lt;p&gt;UE为什么要这么写呢？暂时还没有想明白。如果说为了线程安全，但似乎看代码原版实现也不严谨。&lt;/p&gt;
</description>
        <pubDate>Tue, 31 May 2022 00:00:00 +0000</pubDate>
        <link>http://www.lpq.design/2022/05/ue_delegate_bind/</link>
        <guid isPermaLink="true">http://www.lpq.design/2022/05/ue_delegate_bind/</guid>
        
        <category>UE</category>
        
        <category>C++</category>
        
        <category>Banchmark</category>
        
        
      </item>
    
      <item>
        <title>ALS角色状态机设计</title>
        <description>&lt;p&gt;以下是学习ALS(Advanced Locomotion System)的第二篇笔记。文章主要分析ALS多层状态机设计模式与意图，目的是由简到繁，力求表达如何根据需求不断改进动画设计和其表现。&lt;/p&gt;

&lt;!--more--&gt;

&lt;h2 id=&quot;als的分层状态机&quot;&gt;ALS的分层状态机&lt;/h2&gt;

&lt;p&gt;逻辑中存在多个状态机不一定是分层状态机设计，有可能是多个独立（并行）状态机设计。而分层状态机设计的优势是通过合理的划分抽象逻辑，有效减少各个范围域内的逻辑复杂度，逐级增加复杂表现细节。&lt;/p&gt;

&lt;p&gt;ALS的状态机设计也是如此：&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;首先明确技术范畴，即使用尽量少的动作资源以及依赖运行时骨骼计算来实现3A级别丰富的基础动作和行为动作。&lt;/li&gt;
  &lt;li&gt;然后是需要先确认哪些动作是必不可少的基础资源，哪些动作是通过各种关系叠加融合出来的，还有哪些动作是锦上添花细腻表现。&lt;/li&gt;
  &lt;li&gt;最后再由基础抽象慢慢往上构建整套动作系统，提供灵活的行为扩展。&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;最基本的动作抽象是哪些&quot;&gt;最基本的动作抽象是哪些&lt;/h2&gt;

&lt;p&gt;角色最基础的行为就是站走跑跳，那么最简单的动作状态机实现就是这四个状态的转移。&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;http://www.lpq.design/images/als_state_machine/locomotion_state_1.png&quot; alt=&quot;Locomotion1&quot; /&gt;&lt;/p&gt;

&lt;p&gt;上图左边的状态机设计的问题是走跑变化，或者移动速度位于走跑之间时，动作的融合通常有瑕疵。如果把走跑动作做成混合空间(BlendSpace)，然后通过角色移动速度来指定混合空间的参数，那么即可以简化状态设计，又能得到相对较好的动作表现。&lt;/p&gt;

&lt;p&gt;通常大家会比较在意动作幅度变化较大的跳跃动作。美术会希望区分移动时起跳和原地起跳，甚至移动时的左右脚不一样的起跳动作。策划可能希望从不同高度落地时能播放不同动作。那么改进一下状态机：&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;http://www.lpq.design/images/als_state_machine/locomotion_state_2.png&quot; alt=&quot;Locomotion2&quot; /&gt;&lt;/p&gt;

&lt;p&gt;复杂度已经上来了，但是还能看。等等，我们还有情况没有添加：角色在移动出平台时跌落和落地也要像跳跃一样表现。当然，上面状态机再莽一点也不是不能加，可是跌落又分移动中跌落和站立状态被推跌落。虽然再加转移条件也能看，但是，这时候用分层来设计状态机，就会简单很多。&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;http://www.lpq.design/images/als_state_machine/locomotion_state_3.png&quot; alt=&quot;Locomotion3&quot; /&gt;&lt;/p&gt;

&lt;h2 id=&quot;行为动作与基础移动动作的整合&quot;&gt;行为动作与基础移动动作的整合&lt;/h2&gt;

&lt;p&gt;当角色要表现手持不同武器和装备的行为动作差异时，常规做法是各自武器都单独对应各自的动画资源、状态机和蓝图，通过动态切换蓝图，或切换相对独立的行为状态。&lt;/p&gt;

&lt;p&gt;这么做的好处是：&lt;/p&gt;
&lt;ol&gt;
  &lt;li&gt;各自成套的动作相互独立，互不干扰。&lt;/li&gt;
  &lt;li&gt;非常容易做出差异化的表现。&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;而缺点是：&lt;/p&gt;
&lt;ol&gt;
  &lt;li&gt;成套资源之间切换时动作融合会不流畅、生硬（移动过程中切换尤为明显）。&lt;/li&gt;
  &lt;li&gt;如果还要播放切换的过渡动画，那么对应的控制逻辑复杂。&lt;/li&gt;
  &lt;li&gt;从资源角度来看，这样的制作方案依赖的动作资源多，美术工作量大，程序内存紧张。&lt;/li&gt;
  &lt;li&gt;可能要引入资源预加载相关逻辑来保证动作播放的实时性。&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;除了直接换整套动作，另一个容易想到和制作的方案是上下半身动画分离，下半身只播放移动表现，上半身播放武器装备表现。&lt;/p&gt;

&lt;p&gt;ALS的处理方法非常巧妙，它使用了非常少的资源就整合出流畅丰富的行为表现。当然这一切的代价就是使用者的理解成本急剧上升，CPU解算动画的开销大幅上升。&lt;/p&gt;

&lt;p&gt;ALS定义了三个层次的动画表现：&lt;/p&gt;
&lt;ol&gt;
  &lt;li&gt;全套基础移动的动画表现&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;BaseLayer&lt;/code&gt;&lt;/li&gt;
  &lt;li&gt;当前基础移动的静态姿势&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;BasePoses&lt;/code&gt;，静帧资源。&lt;/li&gt;
  &lt;li&gt;不同持械状态下的行为动画表现&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;OverlayLayer&lt;/code&gt;，静帧资源加少量细节动作。&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;img src=&quot;http://www.lpq.design/images/als_state_machine/overlay_layerd.png&quot; alt=&quot;OverlayLayerd&quot; /&gt;&lt;/p&gt;

&lt;p&gt;蓝图里再运用&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;MakeDynamicAdditive&lt;/code&gt;和&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ApplyAdditive&lt;/code&gt;节点以及&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;LayeredBlendPerBone&lt;/code&gt;来混合出最终姿势效果：&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;http://www.lpq.design/images/als_state_machine/ALS_overlay_eq.png&quot; alt=&quot;ALSOverlay&quot; /&gt;&lt;/p&gt;

&lt;p&gt;除了上面这样极端简化的表述外，ALS还定义了很多细节来保证动作的混合效果：&lt;/p&gt;
&lt;ol&gt;
  &lt;li&gt;生成&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Local Space&lt;/code&gt;和&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Mesh Space&lt;/code&gt;两种空间格式的叠加动画（&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Additive&lt;/code&gt;）来应对不同的需求。&lt;/li&gt;
  &lt;li&gt;动作并不是笼统的全部走叠加逻辑，而是ALS在&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;OverlayPose&lt;/code&gt;里用&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Curves&lt;/code&gt;控制具体部位混合和叠加逻辑。&lt;/li&gt;
  &lt;li&gt;ALS定义了极其细腻部位（&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Legs&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Pelvis&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Spine&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Head&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Arm L&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Arm R&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Hands&lt;/code&gt;）精细调整其表现。&lt;/li&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;OverlayLayer&lt;/code&gt;也包含配合运动的细节，比如持枪奔跑，因为单从&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;BaseLayer&lt;/code&gt;中得到的胳膊表现是错误的，所以持枪状态的&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;OverlayLayer&lt;/code&gt;动画要有单独胳膊的表现。&lt;/li&gt;
&lt;/ol&gt;

&lt;h2 id=&quot;增加运动细节&quot;&gt;增加运动细节&lt;/h2&gt;

&lt;p&gt;以上章节其实已经可以组成相对完善的角色动作表现了，但是玩家或者美术同学经常会反馈角色动作&lt;strong&gt;硬&lt;/strong&gt;：角色移动&lt;strong&gt;僵硬&lt;/strong&gt;，各个姿势切换&lt;strong&gt;生硬&lt;/strong&gt;，总之一句话：“我觉得别扭不流畅！”。&lt;/p&gt;

&lt;p&gt;通常来说问题发展到这里，经常会掉落到资源需求和状态设计的陷阱里：那我们增加状态间的过渡状态呗：站立到跑动、跑动到停止、落地后移动……那个熟悉的复杂的状态联接关系又回来了。比如刚进入的“站立到跑动”的状态又要中断回到“站立”状态！&lt;/p&gt;

&lt;p&gt;那么ALS怎么解决的呢？&lt;/p&gt;

&lt;h3 id=&quot;运动倾斜&quot;&gt;运动倾斜&lt;/h3&gt;

&lt;p&gt;&lt;img src=&quot;http://www.lpq.design/images/als_state_machine/start_run.gif&quot; alt=&quot;StartRun&quot; /&gt;&lt;/p&gt;

&lt;p&gt;角色运动一定要伴随着&lt;strong&gt;重心&lt;/strong&gt;变化，甚至是&lt;strong&gt;重心&lt;/strong&gt;先动，才带动身体与躯干做相应动作。但是在游戏里，因为我们要保证玩家的输入响应，所以&lt;strong&gt;重心&lt;/strong&gt;只能做伴随变化。ALS里把&lt;strong&gt;重心&lt;/strong&gt;变化抽象成独立的运动倾斜混合空间资源&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;BlendSpace&lt;/code&gt;，再根绝当前角色移动速度设置叠加动画&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Additive&lt;/code&gt;的参数，最后得到具有相应运动趋势移动效果。&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;http://www.lpq.design/images/als_state_machine/lean.png&quot; alt=&quot;lean&quot; /&gt;&lt;/p&gt;

&lt;p&gt;光有倾斜还不够，ALS又做了根据加速度播放的起步叠加动作，再进一步增加角色起步加速、急停的表现张力。但是这么做还是造成了动作状态增加，老办法，再上分层状态机。&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;http://www.lpq.design/images/als_state_machine/locomotion_detail.png&quot; alt=&quot;LocomotionDetail&quot; /&gt;&lt;/p&gt;

&lt;p&gt;通常来说，当我们设计基础移动时，要么会选择&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Stand&lt;/code&gt;，&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Walk&lt;/code&gt;，&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Runing&lt;/code&gt;具体细节的包含独立的动画资源的状态。要么会选择一个包含&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Walk&lt;/code&gt;和&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Runing&lt;/code&gt;等资源的混合空间&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;BlendSpace&lt;/code&gt;，再提供一个统一的输出。而上图却把这两种方案一起使用了：&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;MovingStateMachine&lt;/code&gt;提供统一的动画输出，然后再由&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;DetailStateMachine&lt;/code&gt;划分各个细节状态转移的逻辑，最后在&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;LocomotionStateMachine&lt;/code&gt;选择或叠加使用前两个状态。看起来似乎是多此一举，但事实上这么做是非常聪明且细节的设计：&lt;/p&gt;
&lt;ol&gt;
  &lt;li&gt;UE状态机内部状态切换的混合是独立的，不受其他状态机影响。因此&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Moving&lt;/code&gt;中的跑停混合不会被&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Detail&lt;/code&gt;的倾斜干扰，&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;LocomotionStateMachine&lt;/code&gt;中两个状态的切换也是独立的。这样设计可以单独调整各自状态机的混合时间与方式，容易调出满意的效果。&lt;/li&gt;
  &lt;li&gt;角色的移动速度经常会落到走跑的速度区间里，&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;BlendSpace&lt;/code&gt;能提供比两个独立状态更连续顺畅的动画表现。（&lt;a href=&quot;http://www.lpq.design/2021/12/ALS_locomotion/&quot;&gt;可以参考ALS第一篇文章&lt;/a&gt;）&lt;/li&gt;
  &lt;li&gt;分层设计有利于解耦具体的资源依赖，后续我们还会看到对&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;MovingStateMachine&lt;/code&gt;改进，而不影响上层状态机设计。&lt;/li&gt;
&lt;/ol&gt;

&lt;h3 id=&quot;再次设计跳跃&quot;&gt;再次设计跳跃&lt;/h3&gt;

&lt;p&gt;有的游戏跳跃落地后可以马上移动，有的游戏跳跃落地以后必须等角色平衡了才能移动，有的游戏兼而有之普通落地可以移动重落地要等待一会儿。这些方案没有优劣都是选择，但不论怎么选，要想动作更流畅，那上文设计的&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;MainMovementState&lt;/code&gt;就又不够用了。&lt;/p&gt;

&lt;p&gt;首先&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;FallingLoop&lt;/code&gt;还是简单的，无非不是两个姿势的过渡，ALS的处理细节是用&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Blend&lt;/code&gt;节点混合&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;FallingLoop&lt;/code&gt;，再单独处理&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ToLand&lt;/code&gt;状态。这样既能提供连续的变化姿态，又能节省一个状态机设计。&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ToLand&lt;/code&gt;又分落地不动和落地移动两个状态，其中落地移动状态里的资源要使用&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Additive&lt;/code&gt;叠加动画资源来加到基础移动逻辑里，用以表达角色落地后的&lt;strong&gt;重心&lt;/strong&gt;变化。&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;http://www.lpq.design/images/als_state_machine/movement_state.png&quot; alt=&quot;MovementState&quot; /&gt;&lt;/p&gt;

&lt;h3 id=&quot;原地转身&quot;&gt;原地转身&lt;/h3&gt;

&lt;p&gt;作为次世代游戏，怎么能滑着转身呢？！哪怕转身情况很复杂，有左右30°、60°、90°、180°，那也得上，不仅得上，还要动作丝滑反应迅捷。&lt;/p&gt;

&lt;p&gt;ALS把转身的需求拆分了三个维度来处理：&lt;/p&gt;
&lt;ol&gt;
  &lt;li&gt;根据当前旋转状态来区别处理：
    &lt;ol&gt;
      &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Aiming&lt;/code&gt;状态下，角色角度锁死在左右100°之间，超过则硬转，其他角度则通过选择三级&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Spine&lt;/code&gt;骨骼来实现扭腰。这种情况满足了反应迅捷（上半身与镜头方向锁死，下半身播放动画）。&lt;/li&gt;
      &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;VelocityDirection&lt;/code&gt;和&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;LookDirection&lt;/code&gt;状态下只限制头部旋转，角色旋转角度滞后于玩家控制，后续通过旋转动画慢慢补偿。这种情况满足动作丝滑。&lt;/li&gt;
    &lt;/ol&gt;
  &lt;/li&gt;
  &lt;li&gt;左右转身只处理90°的动画，如果待转角度超过了90°，那么再播放一次，提前转到了就结束。&lt;/li&gt;
  &lt;li&gt;转身动画没有使用&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;RootMotion&lt;/code&gt;方案，而是把&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Root&lt;/code&gt;的旋转信息折合到动画&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Curve&lt;/code&gt;(&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;RotationAmount&lt;/code&gt;)中，再由&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Curve&lt;/code&gt;驱动每次旋转的角度增量。这种情况对应提前结束转身的需求。&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;在本篇文章中我们暂时先不看&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Spine&lt;/code&gt;的处理，单说状态机设计的话，那么&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;LocomotionStateMachine&lt;/code&gt;则如下：&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;http://www.lpq.design/images/als_state_machine/locomotion_rotation.png&quot; alt=&quot;LocomotionRotation&quot; /&gt;&lt;/p&gt;

&lt;h3 id=&quot;呼吸相位的匹配&quot;&gt;呼吸相位的匹配&lt;/h3&gt;

&lt;p&gt;基本上动作方案做到这里，感觉已经要满的溢出来了，接下来最多是一些小状态的修修补补。&lt;/p&gt;

&lt;p&gt;但是有天美术贱兮兮的过来跟你说，“我觉得的动作切换还是硬，不流畅。”&lt;/p&gt;

&lt;p&gt;你说，“咋还硬啊，Blend时间都拉长到0.5s了怎么还能硬呢？逐帧看也没有一个跳帧啊！”&lt;/p&gt;

&lt;p&gt;“你说的都对，但是我就算感觉不流畅，那种感觉，你知道吧。站到走，跑到站，端枪换枪，开启瞄准，每一个环节，隐隐约约就感觉不流畅。”&lt;/p&gt;

&lt;p&gt;这种对话一但发展出来，基本上就剩一个相互口腔体操的结果了，但是ALS告诉大家，一切以为的玄学，都还是理论知识不到位。&lt;/p&gt;

&lt;p&gt;通常为了表现角色有生气不呆板，美术同学会给所有基础动作加上一个周期性的呼吸动画。既然有了周期动画，那么多个动画融合的时候，就会产生多个周期相位匹配的问题。如果画两个正弦函数来表示两个动作融合过程的话：&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;http://www.lpq.design/images/als_state_machine/phase_blended.png&quot; alt=&quot;PhaseBlend&quot; /&gt;&lt;/p&gt;

&lt;p&gt;上图同周期的曲线都融合的不流畅，更何况不同动画携带的曲线周期不同呢。所以ALS在&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Locomotion&lt;/code&gt;阶段就剥离了所有移动、行为动画上的周期呼吸动作，在最后输出时通过叠加（&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Additive&lt;/code&gt;）方法再把呼吸动作加回来。这样直接消除了呼吸周期对融合结果的影响。&lt;/p&gt;

&lt;p&gt;这么做也到不是100%完美，它会让部分行为动画，尤其是手，来回乱颤。ALS给出的方案是通过&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Tow Bones IK&lt;/code&gt;再把手定住了。&lt;/p&gt;

&lt;p&gt;随着业务的发展，后期角色可能又要跟乘骑载具等呼吸动画配合，那么相位问题就又出现了。这就是另一个大课题了：多角色的动画配合。简单粗暴的办法就是先把角色身上的呼吸动画禁了。&lt;/p&gt;

&lt;h3 id=&quot;多向移动&quot;&gt;多向移动&lt;/h3&gt;

&lt;p&gt;本来多向移动是跟走跑跳一样基础的内容，应该先做的。但是在多层状态机的设计模式下，后序的改动对前序的影响就不大了，这也是分层状态机的优点。&lt;/p&gt;

&lt;p&gt;&lt;a href=&quot;http://www.lpq.design/2021/12/ALS_locomotion/&quot;&gt;上篇文章&lt;/a&gt;介绍过4向或8向移动对比ALS中的6向移动的区别，这里再介绍一下ALS在六个状态设计和其转移条件。&lt;/p&gt;

&lt;p&gt;ALS设计了6向状态，但是具体在每一个状态中都需要前后左右四个动画参与融合，这个技巧在前文也提到过，它可以保证状态切换的流畅性。比如角色在向前移动突然换向向后移动，此时&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;LocomotionStateMachine&lt;/code&gt;还处在前状态，但是动画已经开始融合后向移动动作了。因此在ALS的&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;LocomotionStateMachine&lt;/code&gt;中的各个状态的职能并不是处理动作融合，而是处理转移逻辑和资源选择。&lt;/p&gt;

&lt;p&gt;对于转移逻辑来说，ALS定义的6个状态不是星形联接的，就算有联接的状态里的转移条件优先级也不都一样。因此角色运动方向就有了偏向性和优先级。举个例子，角色在向左走时突然换向到向右走，那么角色的状态机变化是先左前状态转移到右后状态，待等时机成熟（动作动画差异较小的时候）转移到右前状态。这个说起来可能抽象一点，它在模仿人类运动的偏好，建议大家站起来走几步，体会一下。&lt;/p&gt;

&lt;p&gt;对于资源选择来说，比如说ALS里有两个实现向右走的动画，一个是脸朝右，一个是背朝右。它们都能实现向右走的效果，但是在不同的方向状态里，就做了区分，这一点也是配合实现模仿运动偏好的。基于这个特点，我们也可以再做出其他差异化的动作（两种向后移动），或者减少动作（没有向后移动动作，以左后或右后取而代之）。&lt;/p&gt;

&lt;h3 id=&quot;立定动画和通知事件&quot;&gt;立定动画和通知事件&lt;/h3&gt;

&lt;p&gt;以前在设计立定动画的时候都是增加一个状态，及从移动到站立多一个过渡状态。ALS没有采用这个方案，而是在具体立定状态里处理脚步IK，来保证角色落在地上的脚不会再抬起来了。又通过条件转移或状态进入等的事件通知机制（UE蓝图里&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Notify&lt;/code&gt;或&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Event&lt;/code&gt;）播放动态的蒙太奇动画。&lt;/p&gt;

&lt;p&gt;对于简单情况下这两种方案其实都差不多，ALS方案的优越性要体现在处理不同逻辑下播放不一样的立定动画的需求上。 ALS有个不足是它没有处理玩家停止输入后马上响应的立定动画，这个功能参考现有立定事件通知来处理，问题也不大。&lt;/p&gt;

&lt;h2 id=&quot;状态的可扩展性&quot;&gt;状态的可扩展性&lt;/h2&gt;

&lt;p&gt;分析到这里，我们基本上可以提供一套完整的动画设计了，直到需求又要加跟上面一模一样标准的蹲姿动作。ALS给出的示例是再加一层状态机&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;MainGroundState&lt;/code&gt;来切换站蹲姿态，将&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;MainMovementState&lt;/code&gt;对&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Locomotion&lt;/code&gt;的引用替换成&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;MainGroundState&lt;/code&gt;。&lt;/p&gt;

&lt;p&gt;除了蹲姿以外，我们可能还会需要很多其他特殊的移动状态，比如开车、攀爬、掩体等，这些状态都可以针对&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;MainMovementState&lt;/code&gt;来增加。这里也倒不是每个状态都能无脑添加，其原因是角色最后的动画还受行为叠加(&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;OverlayLayer&lt;/code&gt;)动画影响。如果一个新增的特殊移动状态跟原有动画差异特别巨大的化，那么就要考虑再基础姿势(&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;BasePose&lt;/code&gt;)上也增加一个相应的静帧姿势，用以参与后续混合于叠加。&lt;/p&gt;

&lt;p&gt;最后来到行为姿势(&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;OverlayLayer&lt;/code&gt;)的扩展。其实上文很多艰难繁琐的处理都是为了能叠加行为姿势而不得不选择的取舍。一旦前序工作做好了，那么后面的行为姿势就可以随心所欲的增删改。比如我们持枪，一个基础的&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;OverlayLayer&lt;/code&gt;静帧就能满足，如果要表现持枪的不同阶段，那么就再加一层状态机，选择休闲、警戒、射击三个条件下的静帧资源。同理，我们需要增加其他类型武器，比如手枪、弓箭、乃至RPG，或者我们需要不同的行为表现，比如操作设备，手持电话等等，都可以通过输出&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;OverlayLayer&lt;/code&gt;姿势来实现。&lt;/p&gt;
</description>
        <pubDate>Sat, 14 May 2022 00:00:00 +0000</pubDate>
        <link>http://www.lpq.design/2022/05/ALS_state_machines/</link>
        <guid isPermaLink="true">http://www.lpq.design/2022/05/ALS_state_machines/</guid>
        
        <category>UE</category>
        
        <category>ALS</category>
        
        <category>Animation</category>
        
        <category>Locomotion</category>
        
        
      </item>
    
      <item>
        <title>UE动画蓝图和动画节点拆解</title>
        <description>&lt;p&gt;以下是学习UE动画蓝图和其实现的一些技术细节。&lt;/p&gt;

&lt;!--more--&gt;

&lt;h2 id=&quot;学习参考&quot;&gt;学习参考&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;a href=&quot;https://zhuanlan.zhihu.com/p/69067129&quot;&gt;蓝图解析&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;动画节点的组织&quot;&gt;动画节点的组织&lt;/h2&gt;

&lt;p&gt;动画蓝图中的主要数据结构:&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;http://www.lpq.design/images/ue_animbp/anim_nodes.png&quot; alt=&quot;AnimNode&quot; /&gt;&lt;/p&gt;

&lt;p&gt;动画蓝图（&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;AnimInstanceProxy&lt;/code&gt;）中的所有节点以树状结构组织，它的第一个节点一定是&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;FAnimNode_Root&lt;/code&gt;，初始化时将它赋值给&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;RootNode&lt;/code&gt;。
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;RootNode&lt;/code&gt;虽然存放在数组中第一个，但却是动画蓝图的输出节点(&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Output Pose&lt;/code&gt;)，其他节点都以输出逆序的方式通过&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;FPoseLink&lt;/code&gt;(在蓝图编辑器中表现为有&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Pose&lt;/code&gt;连线)链接成一颗树，执行时采用前序递归遍历。
所有节点大致分为：&lt;/p&gt;
&lt;ol&gt;
  &lt;li&gt;资源播放器(继承自&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;FAnimNode_AssetPlayerBase&lt;/code&gt;)，直接输出动画资源。&lt;/li&gt;
  &lt;li&gt;混合类节点(&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;FAnimNode_.*Blend.*&lt;/code&gt;)负责确认具体的混合方式与动画结果。&lt;/li&gt;
  &lt;li&gt;动画状态和状态机。&lt;/li&gt;
  &lt;li&gt;其他功能节点。&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;对于所有节点来说，具体依赖的参数的计算在函数&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Update_AnyThread&lt;/code&gt;中确定(&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Time&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Weight&lt;/code&gt;)，依赖的&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;DeltaTime&lt;/code&gt;和动画实例等参数在&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;FAnimationUpdateContext&lt;/code&gt;中传递。然后在函数&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Evaluate_AnyThread&lt;/code&gt;中求解，结果保存在&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;FPoseContext&lt;/code&gt;中，语义是到当前这个节点时，应该输出怎样的动画姿势、曲线、属性等。&lt;/p&gt;

&lt;p&gt;总的来说，UE的动画系统是把所有操作都抽象成对&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Pose&lt;/code&gt;的处理，每个操作都是确定如何输出当前角色的&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Pose&lt;/code&gt;。如果要跟Unity对比的话，那么只有后来的&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Playable&lt;/code&gt;和&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Blend Tree&lt;/code&gt;才是等价的，而不是动画状态机。&lt;/p&gt;

&lt;h3 id=&quot;为什么要把参数计算和求解分成两步操作呢&quot;&gt;为什么要把参数计算和求解分成两步操作呢？&lt;/h3&gt;

&lt;p&gt;理论上计算出当前参数以后就可以直接求解&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Pose&lt;/code&gt;，但是在设计中，&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Update&lt;/code&gt;是个相对轻量的计算，而&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Evaluate&lt;/code&gt;是消耗非常大的过程。当把逻辑拆成两个独立的步骤后，更高层的逻辑可以灵活的调配具体的执行策略。
比如&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;AnimInstance&lt;/code&gt;可以选择跳过权重为0的动画节点&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Evaluate&lt;/code&gt;过程来节省CPU资源。各个动画节点的实现可以专注于自身逻辑，无需了解上层模块的执行策略。&lt;/p&gt;

&lt;h2 id=&quot;动画状态机和状态的实现&quot;&gt;动画状态机和状态的实现&lt;/h2&gt;

&lt;h3 id=&quot;状态机&quot;&gt;状态机&lt;/h3&gt;

&lt;p&gt;由上图可见，状态机与其他节点一样也是继承自&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;FAnimNode_Base&lt;/code&gt;，所以UE动画状态机天然可以树状分层组合起来，多个状态机可以通过各种混合节点来输出结果。动画状态机会在&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Update&lt;/code&gt;阶段遍历合适的转移条件，当触发转移条件时，状态机立刻会切换到下一个状态，哪怕两个状态存在融合过程。在&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Evaluate&lt;/code&gt;阶段，所有激活的状态都会参与求解与结果融合，具体的融合方式可以在条件跳转中定义，融合权重不仅受融合方式影响，也受当期所有激活状态数量影响。每个状态机都可以单独定义每&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Tick&lt;/code&gt;最多处理几次状态跳转。&lt;/p&gt;

&lt;h3 id=&quot;状态&quot;&gt;状态&lt;/h3&gt;

&lt;p&gt;状态机里的状态没有特殊定义，而是通过&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;FPoseLink&lt;/code&gt;数组来记录所有状态的根节点(&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;FAnimNode_Root&lt;/code&gt;)，然后像动画蓝图一样树形链接起状态里的其他节点。理论上状态不能输入参数，只能输出&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Pose&lt;/code&gt;，在具体状态实现中，状态除了能直接引用资源播放器(&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;FAnimNode_AssetPlayerBase&lt;/code&gt;）以外，还可以通过&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;FAnimNode_UseCachedPose&lt;/code&gt;引用&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;FAnimNode_SaveCachedPose&lt;/code&gt;中“保存”的&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Pose&lt;/code&gt;。这个“保存”是语义上的，实现上它只是一个命名的链接，编译以后可以直接串起俩边的节点。&lt;/p&gt;

&lt;h3 id=&quot;导管&quot;&gt;导管&lt;/h3&gt;

&lt;p&gt;导管(&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Conduit&lt;/code&gt;)是一个特化的状态，它只参与条件跳转，不能参与求解&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Evaluate&lt;/code&gt;过程。导管可以有效的简化状态之间的星型连接。&lt;/p&gt;

&lt;h3 id=&quot;条件&quot;&gt;条件&lt;/h3&gt;

&lt;p&gt;条件用以标记状态之间的跳转逻辑和指定状态间的融合规则。每一个状态在更新阶段会遍历自身所有跳转到其他状态的条件，每个条件的跳转逻辑是一段蓝图表达式，也可以是本地转移委托&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;NativeTransitionDelegate&lt;/code&gt;。后者在代码中通过&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;AnimInstance::AddNativeTransitionBinding&lt;/code&gt;函数指定具体状态机和状态名字进行绑定。跳转条件一旦被本地委托绑定后，那么蓝图逻辑就会被忽略，但是这里有Bug，可能之前也没什么机会用所以没暴露。
每个刚达成跳转逻辑的条件会被加入已激活的条件数组中，每个激活的条件一旦融合完成，那么就会移出，这个已激活的数组顺序是关键的不能随便变更的。&lt;/p&gt;

&lt;h3 id=&quot;状态融合&quot;&gt;状态融合&lt;/h3&gt;

&lt;p&gt;在求解阶段，所有已激活的状态会一并融合。融合公式为
\(State_0 \overset {AlphaT_1} \longrightarrow State_1 \overset {AlphaT_2} \longrightarrow State_2 \dots \overset {AlphaT_n} \longrightarrow State_n \\
\ \\
\begin{align*}
Bland\ State_n &amp;amp;= AlphaT_n\\
Bland\ State_{n-1} &amp;amp;= AlphaT_{n-1} * (1 - AlphaT_n) \\
&amp;amp;= AlphaT_{n-1} * Bland\ State_n * (1 / AlphaT_n - 1) \\
Bland\ State_{n-2} &amp;amp;= AlphaT_{n-2} * (1 - AlphaT_{n-1}) * (1 - AlphaT_n)\\
&amp;amp;= AlphaT_{n-2} * Bland\ State_{n-1} * (1 / AlphaT_{n-1} - 1) \\
\dots\\
Bland\ State_0 &amp;amp;= (1 - AplahT_1) * (1 - AlphaT_2) * \dots * (1 - AlphaT_n)\\
&amp;amp;= 1 * Bland\ State_1 * (1 / AlphaT_1 - 1)\\
\end{align*}\)&lt;/p&gt;

&lt;p&gt;说人话就是按照状态激活顺序，从前到后，前面的两个状态融合结果当作后续融合的前置结果：各个状态是非线性叠加的，而且最新进入的状态条件就算提前完成融合，那么也不会中断前序状态参与融合。
源码中仅通过一次遍历，就计算出当前状态融合权重：&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;float TotalWeight = 0;
for(int Index = 0; Index &amp;lt; Transitions; ++Index) {
    if(Index &amp;gt; 0){
        TotalWeight *= (1 - Transitions[Index].Alpha);
    }else if(Transitions[Index].PreviousState == CurState){
        TotalWeight += (1 - Transitions[Index].Alpha);
    }

    if(Transitions[Index].NextState == CurState){
        TotalWeight += Transitions[Index].Alpha;
    }
}
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;重点是&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;PreviousState&lt;/code&gt;和&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;NextState&lt;/code&gt;权重是相加，其他中间过程是相乘。&lt;/p&gt;

&lt;h2 id=&quot;动画蓝图中的性能问题&quot;&gt;动画蓝图中的性能问题&lt;/h2&gt;

&lt;p&gt;动画蓝图主要有两个更新逻辑，一个是事件更新，一个是动画实例更新。其中事件更新跟普通蓝图一样，走蓝图虚拟机求解。而动画实例更新则完全按照预先编译好的节点组织成一颗树，通过递归的方式直接调用本地逻辑完成的，因此单纯的动画节点会非常高效。
另一方面，这也要求在制作中尽量避免添加太多蓝图事件，更要避免逻辑非常重的&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Update&lt;/code&gt;蓝图事件，不然就上&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;UAnimInstance::NativeUpdateAnimation&lt;/code&gt;函数覆写。
当然，对于状态机中条件跳转，还是使用蓝图来求解的。虽然UE提供了条件的本地委托来接管状态之间的转移逻辑，但是仍然不合适：&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;本地代码太不容易读：语法复杂，定义与声明繁琐。手动输入各个名字容易发生错误。&lt;/li&gt;
  &lt;li&gt;破坏数据与功能定义的一致性和局部性。对状态机进行增删查改操作时引入不必要的复杂度和心智负担&lt;/li&gt;
  &lt;li&gt;本地条件转移逻辑有bug。&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;动画蓝图中的各节点数据结构关系：&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;Editor:                Deserialize:         Runtime:
-----------------     ----------------     ------------
|AnimGraphNode_*| ==&amp;gt; |BackedMachine*| ==&amp;gt; |AnimNode_*|
-----------------     ----------------     ------------
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;
</description>
        <pubDate>Mon, 07 Mar 2022 00:00:00 +0000</pubDate>
        <link>http://www.lpq.design/2022/03/UE_AnimBP/</link>
        <guid isPermaLink="true">http://www.lpq.design/2022/03/UE_AnimBP/</guid>
        
        <category>UE</category>
        
        
      </item>
    
      <item>
        <title>UE ALS学习笔记之基础运动动画控制</title>
        <description>&lt;p&gt;以下作为学习ALS(Advanced Locomotion System)的笔记，相比于网上其他大佬总结的ALS教程“它是什么”，本文侧重于“尝试猜测ALS为什么这么实现，想解决什么问题”。&lt;/p&gt;

&lt;p&gt;因为作为UE初学者，相比于蓝图，我更容易理解代码逻辑。所以本文除了基于原始&lt;a href=&quot;https://www.unrealengine.com/marketplace/zh-CN/product/advanced-locomotion-system-v1&quot;&gt;ALS插件&lt;/a&gt;的对比与学习以外，主要还是依据&lt;a href=&quot;https://github.com/dyanikoglu/ALS-Community&quot;&gt;ALS-Community&lt;/a&gt;以及其C++逻辑实现来分析的。&lt;/p&gt;

&lt;!--more--&gt;

&lt;h2 id=&quot;学习参考&quot;&gt;学习参考&lt;/h2&gt;

&lt;p&gt;常规介绍性的内容就不啰嗦了，下面列一些学习过程中看过的文章。&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;a href=&quot;https://www.bilibili.com/read/cv9226463&quot;&gt;ALS一些基础概念和分块介绍&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://zhuanlan.zhihu.com/p/159646345&quot;&gt;ALS详细的拆解&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://zhuanlan.zhihu.com/p/141266454&quot;&gt;系列专栏里关于ALS的篇章&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://zhuanlan.zhihu.com/p/439150072&quot;&gt;UE的动画基础&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;als是如何确定走跑姿势的&quot;&gt;ALS是如何确定走跑姿势的&lt;/h2&gt;

&lt;h3 id=&quot;动画依赖的变量&quot;&gt;动画依赖的变量&lt;/h3&gt;

&lt;p&gt;ALS有几个关键性参数来确定走跑或者疾跑姿势。按更新顺序如下，&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;C++&lt;/code&gt;表示ALS-Community实现。&lt;/p&gt;

&lt;p&gt;Character相关：&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Desired Gait&lt;/code&gt;，&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ALS_Base_CharacterBP&lt;/code&gt;里接受玩家输入事件的变量，C++里在&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ALSBaseCharacter.h&lt;/code&gt;。&lt;/li&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Allowed Gait&lt;/code&gt;，&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Actual Gait&lt;/code&gt;，临时变量，C++&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ALSBaseCharacter::UpdateCharacterMovement()&lt;/code&gt;里逻辑比较明显。
    &lt;ul&gt;
      &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Allowed Gait&lt;/code&gt;受当前&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Stance&lt;/code&gt;站蹲姿态影响。&lt;/li&gt;
      &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Actual Gait&lt;/code&gt;受当前&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Speed&lt;/code&gt;和&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CharacterMovementComponent&lt;/code&gt;里配置的走跑速度影响。&lt;/li&gt;
    &lt;/ul&gt;
  &lt;/li&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Gait&lt;/code&gt;，&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ALS_Base_CharacterBP&lt;/code&gt;里和&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ALS_AnimBP&lt;/code&gt;里都有的同名变量，但枚举类型不同。
    &lt;ul&gt;
      &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ALS_Base_CharacterBP&lt;/code&gt;里依据上面Desired Gait和Allowed Gait得来，作为最后的状态枚举。&lt;/li&gt;
      &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ALS_AnimBP&lt;/code&gt;里的&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Gait&lt;/code&gt;以下动画再提及的Gait就是特指&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ALS_AnimBP&lt;/code&gt;里的变量。
        &lt;ul&gt;
          &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ALS_AnimBP&lt;/code&gt;里通过&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Event Blueprint Update Animation&lt;/code&gt; =&amp;gt; &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Update Character Info&lt;/code&gt;节点赋值。&lt;/li&gt;
          &lt;li&gt;C++里通过Character直接给AnimInstance赋值。&lt;/li&gt;
        &lt;/ul&gt;
      &lt;/li&gt;
    &lt;/ul&gt;
  &lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Animation中混合空间相关的变量，&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ALS_AnimBP&lt;/code&gt;在&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Event Blueprint Update Animation&lt;/code&gt; =&amp;gt; &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Update Movement Values&lt;/code&gt;节点里计算。C++在&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ALSCharacterAnimInstance::NativeUpdateAnimation()&lt;/code&gt;的&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;UpdateMovementValues()&lt;/code&gt;计算。&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Walk Run Blend&lt;/code&gt;，C++&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Grounded.WalkRunBlend&lt;/code&gt;。&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Gait&lt;/code&gt;是走为0，其他（跑、疾跑）为1。&lt;/li&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Stride Blend&lt;/code&gt;，C++&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Grounded.StrideBlend&lt;/code&gt;。根据当前移动速度，再以当前动画曲线和配置曲线来计算当前步幅。&lt;/li&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Standing Play Rate&lt;/code&gt;，C++&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Grounded.StandingPlayRate&lt;/code&gt;。以速度和当前走跑姿势融合程度来确定动画播放速率。这个是平顺实现走跑过度的关键。&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;曲线&quot;&gt;曲线&lt;/h3&gt;

&lt;p&gt;动画曲线&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Weight_Gait&lt;/code&gt;，C++里叫&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;W_Gait&lt;/code&gt;。这个曲线是定义在基础locomotion动画资源里的，原始动画资源位于&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;AnimationExamples/Base/Locomotion&lt;/code&gt;文件夹下各个Walk、Run、Sprint动作。&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;Walk:   Weight_Gait = 1
Run:    Weight_Gait = 2
Sprint: Weight_Gait = 3
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;除了动画曲线外，还非常依赖额外配置的动画混合曲线来计算出当前移动速度下准确的步幅。资源位于&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Data/Curves/AnimationBlendCurves&lt;/code&gt;，影响步幅的曲线是&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;StrideBlend_N_Run&lt;/code&gt;等。&lt;/p&gt;

&lt;p&gt;相比于Unity，UE动画曲线和曲线资源可以解决非常多原来看起来很困难的问题。比如之前项目就存在角色移动速度和动画融合后的速度不匹配的问题(动画虽然是线性融合的，但是动画Key出来的结果却是非线性的，因此融合参数也就难确定)。当时找了很多方向，都没解决好。而现在类比到ALS中，使用类似&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;StrideBlend_N_Run&lt;/code&gt;的曲线修正一下融合参数就可以完美解决。&lt;/p&gt;

&lt;h3 id=&quot;走跑混合为什么要采用步幅的混合方式&quot;&gt;走跑混合——为什么要采用步幅的混合方式&lt;/h3&gt;

&lt;p&gt;ALS使用一个二维混合空间(BlendSpace2D)来实现走跑姿态混合是非常厉害的方案。比如打开&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ALS_N_WalkRun_F&lt;/code&gt;资源，它横轴参数是步幅，纵轴参数是走或跑，四个sample点分别为走动作、跑动作、走姿态下步幅为0的动作、跑姿态下步幅为0的动作。
使用走和跑动作来融合问题不大，但是走姿态下步幅为0的动作这个设计是非常棒的。相比于Unity的混合树(BlendTree)来说，Unity项目通常使用空闲站立、走动作、跑动作来混合出不同的速度下的动作表现。这样做不是不能用，但是当融合参数处于站走混合过程中或走跑混合时，它输出的动作大概率有问题。包括不限于走跑动作幅度差别大导致的屈腿奇怪姿势；走跑相位不一致导致的颠儿腿；站走、走跑时手臂姿势差别大导致的不自然摆臂。
ALS中，走姿态下步幅为0的动作其实就是一个站立动作，跑姿态下步幅为0的动作有点像中学跑操时预备跑的动作：屈腿站立，提手屈肘。当这个预备跑动作和跑动作再进行融合时，那它就能刚好解决上面说的走跑步幅、相位、手臂姿势有差异的问题，多了新的采样维度——步幅。
ALS主要是使用步幅来控制动画表现的移动速度的，半跑半走的速度就使用0.5步幅的跑姿态(事实上是由上面提过的混合曲线确认的数值，不是绝对的0.5)。再对比Unity项目（包括官方指导和Animancer插件，都是如此），普遍会使用0.5权重走动作和0.5权重跑动作融合。
最后，其实ALS可以不把走跑动作放到一个BlendSpace2D里的，而是单独放两个BlendSpace1D。虽然ALS在计算&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;WalkRunBlend&lt;/code&gt;参数时只会明确输出0或1，不会输出其他中间值。但它仍然没有这么做，我猜测一是为了减少蓝图里直接编辑的资源量，二是可以方便再扩展新的混合策略。
ALS使用&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Stride Blend&lt;/code&gt;，&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Walk Run Blend&lt;/code&gt;，&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Standing Play Rate&lt;/code&gt;三个参数来唯一确认走跑6个方向混合动画。&lt;/p&gt;

&lt;p&gt;总的来说，使用步幅的混合方式能在避免走跑动作混合时出现的一些细节问题的同时，还能提供更平顺自然的动作切换表现。&lt;/p&gt;

&lt;h3 id=&quot;疾跑处理&quot;&gt;疾跑处理&lt;/h3&gt;

&lt;p&gt;还是说回Unity，以前项目在处理疾跑的时候也有两个阶段，第一阶段是直接都放在BlendTree里混合，提供了四个Sample：&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Stand, Walk, Run, Sprint&lt;/code&gt;，通过速度参数控制混合比例。但是这么做带来的问题是跑到疾跑的过渡表现不好看。后一阶段就拆开了疾跑，由基础动作Fade到疾跑动作。依然存在的问题是Fade过程中有可能出现因前后动作相位不一致导致的颠儿腿现象。
&lt;strong&gt;为什么不把疾跑也放到走跑混合空间里做？&lt;/strong&gt; 
一句话解释就是疾跑动作和跑动作的差异太大了，同时混合效果不好。&lt;/p&gt;

&lt;p&gt;ALS对疾跑处理的比较细腻，先计算了归一化的加速度&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;RelativeAccelerationAmount&lt;/code&gt;，然后用这个值混合了疾跑动画和带加速度的疾跑动画(会俯身)。
除了使用&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Gait&lt;/code&gt;来切换前向移动和疾跑移动，ALS还提供了一个Mask机制。我认为这个机制也非常好。
很多动画之间是互斥的，如果同时存在就会穿帮。之前项目处理这种情况的时候多半要硬写逻辑，处理起来还很不顺手，多次修改能让后来人绝望。而这个Mask机制则是非常灵活和容易扩展的，只要在必要的动作资源上加上对应的曲线就可以了。
已经切换过的动作再经过&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Mask_Spint&lt;/code&gt;曲线再做一次确认，如果mask值为1，那么就不选择疾跑动作。至此角色的6个方向走跑动作就完成了所有的预处理。&lt;/p&gt;

&lt;p&gt;我在ALS里疾跑切换中没有看到相关对于动作相位的同步控制，可能ALS也会有类似问题，也可能现阶段只是我没找到相关逻辑，后面再研究一下。&lt;/p&gt;

&lt;h2 id=&quot;als如何混合多方向移动动作的&quot;&gt;ALS如何混合多方向移动动作的&lt;/h2&gt;

&lt;h3 id=&quot;为什么不是8方向混合&quot;&gt;为什么不是8方向混合&lt;/h3&gt;

&lt;p&gt;ALS使用一个子状态机来处理运动方向上的融合，它特殊在是6个方向（前&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Move F&lt;/code&gt;、后&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Move B&lt;/code&gt;、左前&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Move LF&lt;/code&gt;、左后&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Move LB&lt;/code&gt;、右前&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Move RF&lt;/code&gt;、右后&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Move RB&lt;/code&gt;）的融合而不是8个方向的。而理解这个设计的巧妙，还是要先说一下之前是怎么做4、8方向移动混合的：美术输出前后左右移动动作，然后根据速度方向和面向角度差计算混合参数，斜向移动动作是相邻的两个动作混合出来的。如果觉得动作不够精细，那么就再加4个45°的斜向动作。但是它仍然存在较多问题：&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;融合出来的斜向动作姿势与美术想要的质感差距较大且无法调整。如果强行纠正只能再增加新的斜向动作作为采样点，这样带来的资源数和内存开销的激增。&lt;/li&gt;
  &lt;li&gt;融合出来的斜向动作的移动速度不是线性的且不好预知的。如果基础动作也使用RootMotion来驱动角色移动，那么会对逻辑提出较大挑战；如果放任不管，那么斜向移动的角色会非常明显的黄油脚。&lt;/li&gt;
  &lt;li&gt;融合参数在计算时其实是有小坑的：融合参数如果通过线性方法计算得出，那么是不准确的。需要使用主采样方向的正切方法计算。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;移动方向在切换时，或特定的融合角度，角色动画容易发生绊脚的问题。&lt;/strong&gt; 如果说前面三点还能勉强忍受的话，那么这一点美术同学是真的不能接受。&lt;/li&gt;
&lt;/ol&gt;

&lt;h3 id=&quot;方向混合的参数&quot;&gt;方向混合的参数&lt;/h3&gt;

&lt;p&gt;在研究ALS的6向混合方法时候给我最大的困扰是：它怎么向着左或者右水平移动呢？需要左前和左后来混合么？
这里先把怎么确认角色旋转方向的问题放一下以后再分析，原因是角色的面向、移动方向、瞄准方向其实是一个细节非常多的大议题。本文先重点关注一下当方向确认后，如何选用和切换角色具体方向上的移动动作。&lt;/p&gt;

&lt;p&gt;ALS里主要通过&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Movement Direction&lt;/code&gt;枚举来确认基本的方向动作，它只有前后前后左右四个值，通过&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;UpdateGraph&lt;/code&gt; =&amp;gt; &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Do While Moving&lt;/code&gt; =&amp;gt; &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Update Rotation Values&lt;/code&gt;中计算。相较于ALS版本复杂的线框图，C++中&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;UALSCharacterAnimInstance::CalculateMovementDirection()&lt;/code&gt;和&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;UALSMathLibrary::CalculateQuadrant()&lt;/code&gt;两个函数的逻辑非常清晰。方向枚举的切换里面有个动态范围的细节，如果感兴趣可以单独看一下。&lt;/p&gt;

&lt;p&gt;除了&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Movement Direction&lt;/code&gt;枚举以外，还要计算 &lt;strong&gt;4个&lt;/strong&gt; (居然不是6个动作)方向上动作的融合参数&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Velocity Blend&lt;/code&gt;，包含四个分量：&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;F, B, L, R&lt;/code&gt;。C++的计算逻辑在&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;UALSCharacterAnimInstance::UPdateMovementValues()&lt;/code&gt; =&amp;gt; &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;UALSCharacterAnimInstance::CalculateVelocityBlend()&lt;/code&gt;。细节包括归一化和速度插值等。&lt;/p&gt;

&lt;p&gt;接下来是解决上一小节四个问题的关键性参数：&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;FYaw, BYaw, LYaw, RYaw&lt;/code&gt;。它们在动画蓝图里被6个方向状态赋值给曲线&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;YawOffset&lt;/code&gt;，然后在&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ALS_Base_CharacterBP&lt;/code&gt; =&amp;gt; &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Update Grounded Rotation&lt;/code&gt;蓝图里读取该曲线数值再作用于角色旋转。其中包含两个(&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;YawOffset_FB, YawOffset_LR&lt;/code&gt;)配置曲线资源来计算输出数值。&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;状态&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Move F&lt;/code&gt;，设置&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;FYaw&lt;/code&gt;数值到曲线&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;YawOffset&lt;/code&gt;。&lt;/li&gt;
  &lt;li&gt;状态&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Move B&lt;/code&gt;，设置&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;BYaw&lt;/code&gt;数值到曲线&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;YawOffset&lt;/code&gt;。&lt;/li&gt;
  &lt;li&gt;状态&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Move LF&lt;/code&gt;和&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Move LB&lt;/code&gt;，设置&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;LYaw&lt;/code&gt;数值到曲线&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;YawOffset&lt;/code&gt;。&lt;/li&gt;
  &lt;li&gt;状态&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Move RF&lt;/code&gt;和&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Move RB&lt;/code&gt;，设置&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;RYaw&lt;/code&gt;数值到曲线&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;YawOffset&lt;/code&gt;。&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;这个&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;YawOffset&lt;/code&gt;表示的意思是在当前移动动作下，再补多少角度可以匹配到垂直或水平移动方向上。举个例子，其中涉及数值只是定性的表达意思，非定量的准确数值。&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;角色前向移动，使用前向动作，&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;YawOffset&lt;/code&gt;为0，角色不额外旋转。&lt;/li&gt;
  &lt;li&gt;角色右向移动（+90°），使用右前动作(运动方向是+60°)，&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;YawOffset&lt;/code&gt;为30°，角色再额外向右旋转30度。&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;所以到这里先回答一下上一个小节的问题，ALS怎么解决因各个斜向运动导致的融合问题呢？
&lt;strong&gt;答案是不融合动作&lt;/strong&gt;，跳出旧逻辑，直接使用额外的角色旋转控制，把所有斜向速度方向都纠正成相对于当前移动动作方向再做偏移的方向。随后再在蓝图中配合角色注视逻辑，把头扭到摄像机方向。这样即实现了多向运动，又避免动作融合带来的种种困难。
我认为这块逻辑有点脑筋急转弯的意思，想到了，方案很简单，没想到就很困难。&lt;/p&gt;

&lt;h3 id=&quot;各个方向状态的转移&quot;&gt;各个方向状态的转移&lt;/h3&gt;

&lt;p&gt;先说明的一点是6个移动状态里都是4个动作参与混合的，分别由结构体&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;VelocityBlend {F, B, L, R}&lt;/code&gt;数值控制权重。但是它们每一个时刻最多只有两个动作参与融合，细节其实在曲线资源&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;YawOffset_FB&lt;/code&gt;和&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;YawOffset_LR&lt;/code&gt;中配置体现的。&lt;/p&gt;

&lt;p&gt;前和后状态的切换是最直接的，只要&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Movement Direction&lt;/code&gt;是前或者后，就会切换到对应状态上。向左和向右切换则包含了一些有意思的细节：&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;处于前或者后时切换左右，直接切换到最近的对应方向：前状态+左输入=&amp;gt;左前状态，后状态+左输入=&amp;gt;左后状态。&lt;/li&gt;
  &lt;li&gt;左右状态互切是按照对角切换的：左前状态+右输入=&amp;gt;右后状态，左后状态+右输入=&amp;gt;右前状态。&lt;/li&gt;
  &lt;li&gt;左右状态会根据上层&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;HipOrientation_Bias&lt;/code&gt;曲线(Overlay相关动作资源携带)和&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Feet_Crossing&lt;/code&gt;(Base locomotion相关动作资源携带)自动切换左前左后(右前右后)。
    &lt;ol&gt;
      &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;HipOrientation_Bias&lt;/code&gt;曲线表现当前overlay姿态优先使用哪个方向。0表示默认，1表示优先使用右前或者左后动作(大部分双手瞄准动作)，-1表示优先使用左前或者右后动作(单手持手枪瞄准动作)。为了让动作姿态更舒展，而选用更合适的斜向动作。&lt;/li&gt;
      &lt;li&gt;对于&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Feet_Crossing&lt;/code&gt;曲线来说，值为1表示当前动作两腿正在交叉，不允许进行左右状态切换。0表示没有交叉，可以进行切换左右脚。&lt;strong&gt;强化解决绊腿问题&lt;/strong&gt;。&lt;/li&gt;
    &lt;/ol&gt;
  &lt;/li&gt;
  &lt;li&gt;没有特殊设定(&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;HipOrientation_Bias&lt;/code&gt;曲线)影响时，左右移动会在左后或者右后状态完整切换完自动再切换到左前或者右前状态。
    &lt;ol&gt;
      &lt;li&gt;表现为只要没有输入“后”操作，就尽量选用包含前向的动作。&lt;/li&gt;
      &lt;li&gt;如果是从后状态切换到左右的，那么会执行一个完整的状态过度，再切换到目标状态，以此实现更平顺的姿态切换。&lt;/li&gt;
    &lt;/ol&gt;
  &lt;/li&gt;
&lt;/ol&gt;

&lt;h3 id=&quot;运动倾斜&quot;&gt;运动倾斜&lt;/h3&gt;

&lt;p&gt;一个锦上添花的小细节，但是之前项目会单独制作一个过度动画来实现。
ALS使用角色运动的加速度信息和一个叠加(Additived)的BlendSpace2D动画资源来混合。好处是能提供更丰富，逻辑更简单的实现方式，缺点是需要额外的混合计算开销。&lt;/p&gt;

&lt;h2 id=&quot;再谈als中曲线的应用&quot;&gt;再谈ALS中曲线的应用&lt;/h2&gt;

&lt;p&gt;朴素的来说，大家一般设计动画驱动逻辑时都想做好逻辑和表现的剥离，降低逻辑与动画的耦合。但是这么会做大大降低逻辑对动画精细表现的控制能力。
而ALS则提供了一个非常好的耦合方案，让逻辑控制动画、动画控制逻辑很好的配合在一起：在各个模块里构成“&lt;em&gt;逻辑驱动状态——状态驱动动画——多处动画曲线的混合结果修改逻辑状态&lt;/em&gt;”的三明治结构。
另外一个重点就是使用配置曲线来处理大量非线性数值逻辑，简化美术制作以及程序逻辑。可以看到后面还会在角色攀爬、翻跨实现里发挥更重要的作用。
当然它的缺点就是在初期非常难以理解，有大量的Magic Number、Bias、Clamp行为导致目的和语义不明确。&lt;/p&gt;
</description>
        <pubDate>Wed, 01 Dec 2021 00:00:00 +0000</pubDate>
        <link>http://www.lpq.design/2021/12/ALS_locomotion/</link>
        <guid isPermaLink="true">http://www.lpq.design/2021/12/ALS_locomotion/</guid>
        
        <category>UE</category>
        
        <category>ALS</category>
        
        <category>Animation</category>
        
        
      </item>
    
      <item>
        <title>基于相似帧的动作融合与动作匹配的小样</title>
        <description>&lt;p&gt;本文使用向量余弦判断相似的方法，来选取两个动画中最接近的一帧进行融合，以达到相对流畅的融合效果。另一方面对于一段循环动作，根据当前运动姿态来自动匹配最佳的动画片段。&lt;/p&gt;

&lt;!--more--&gt;

&lt;h2 id=&quot;姿态相似&quot;&gt;姿态相似&lt;/h2&gt;

&lt;p&gt;两个动作是否相似，可以简化成两个动作里所有骨骼的位置旋转和缩放信息组成的矩阵是否相似。&lt;/p&gt;

&lt;p&gt;在开始的尝试里，本来打算使用SVD降维骨骼矩阵来得到特征向量，但是困难出现在如何利用特征向量上。&lt;/p&gt;

&lt;p&gt;后来开始尝试一些朴素的向量相似方法，比如欧式距离和余弦角度。使用向量相似的一个好处是可以分别设置权重来凸显相对重要的维度信息。&lt;/p&gt;

&lt;p&gt;假设一个动作的一帧是一个矩阵，定义每一行是各个骨骼，列是Transform的信息。因此一个动作就是若干个这样的矩阵。&lt;/p&gt;

\[\begin{Vmatrix} \ &amp;amp; pos &amp;amp; rot &amp;amp; scale \\ root &amp;amp; \ddots \\ bip1 \ &amp;amp; \ &amp;amp; \ddots \\ bip2 &amp;amp; \ &amp;amp; \ &amp;amp; \ddots \end{Vmatrix}\]

&lt;p&gt;两个姿态$Mi$和$Mj$（一个动作里的两帧或者两个动作里的各一帧）的欧式距离就是
\(Distance(Mi,Mj) = \frac {\sum \sqrt{(Mi_n - Mj_n)^2}} n\)&lt;/p&gt;

&lt;p&gt;下标n表示矩阵的某一列。 同理，两个姿态的余弦值是&lt;/p&gt;

\[Cos(Mi,Mj) = \sum \frac {Mi_n \cdot Mj_n} {\left\| Mi_n \right\| \times \left\| Mj_n \right\|} \div n\]

&lt;p&gt;对于欧式距离来说，值越小，姿态越相似；对于余弦来说，值越大越相似。
如果希望加权，那么在每个列上乘相应系数就行，但是下文没有精细调整各个维度的权重，使用的平均值。&lt;/p&gt;

&lt;h2 id=&quot;动作融合的相似矩阵&quot;&gt;动作融合的相似矩阵&lt;/h2&gt;

&lt;p&gt;有了上文的相似判定后，我们就可以定义动作A如何更好的切换到动作B了。通常来说，我们会定义每个动作的起始帧和结束帧都和Idle动画接近，但是Idle本身也是有呼吸运动的。如果这个呼吸幅度不算小，那么这个衔接和融合就不顺畅了。如果角色中奔跑过程中再播放全身动作，那么这个衔接就变的更困难了。&lt;/p&gt;

&lt;p&gt;如果能枚举出每个动作每一帧到其他循环动作最相似的一帧是多少，那么在这个动作被打断时就把目标循环动作切换到相似帧就可以让动画融合更平顺。&lt;/p&gt;

&lt;p&gt;先定义动作融合的相似矩阵，以余弦相似为例：&lt;/p&gt;

\[clip: attack
\begin{Vmatrix} 
\ &amp;amp; stand &amp;amp; walk &amp;amp; run\\
t0 &amp;amp; (t0:0.99)	&amp;amp;  (t26:0.93) &amp;amp; (t13:0.59)\\
t1 &amp;amp; (t49:0.79) &amp;amp; (t29:0.86) &amp;amp; (t9:0.60)\\
t2 &amp;amp; (t50:0.74) &amp;amp; (t30:0.83) &amp;amp; (t9:0.59)\\
\dots &amp;amp; \dots  &amp;amp; \dots  &amp;amp; \dots \\
t37 &amp;amp; (t7:0.97) &amp;amp; (t29:0.96) &amp;amp; (t12:0.61)\\
t38 &amp;amp; (t4:0.99) &amp;amp; (t27:0.95) &amp;amp; (t12:0.60)\\
\end{Vmatrix}\]

&lt;p&gt;第一行是要切换的目标动作集合，第一列是当前动作的每一帧，矩阵中的各个元素是当前动作帧要切换到目标动作的最佳帧和相似度。&lt;/p&gt;

&lt;p&gt;到这里，动作切换时只要知道当前动作运行了多久，目标动作是哪个，那么就查表可知结果。下面分别是站立和奔跑中释放技能的案例。&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;http://www.lpq.design/images/clip_frame_similar/stand_attack_fadeout.gif&quot; alt=&quot;image&quot; /&gt;
&lt;img src=&quot;http://www.lpq.design/images/clip_frame_similar/run_attack_fadeout.gif&quot; alt=&quot;image&quot; /&gt;&lt;/p&gt;

&lt;h2 id=&quot;筛选最佳衔接动作&quot;&gt;筛选最佳衔接动作&lt;/h2&gt;

&lt;p&gt;还是利用上文提到的相似矩阵，如果我们变化一下参数，枚举出每个循环动作的每一帧到其他动作的第一帧的相似度，那么就可以横向判断比较接下来的哪个动作更合适播放。&lt;/p&gt;

\[clip:run
\begin{Vmatrix}
\ &amp;amp; stand\ jump &amp;amp; left\ jump &amp;amp; right\ jump \\
t0 &amp;amp; (t0:0.89) &amp;amp; (t0:0.92) &amp;amp; (t0:0.94) \\
t1 &amp;amp; (t0:0.89) &amp;amp; (t0:0.91) &amp;amp; (t0:0.93) \\
t2 &amp;amp; (t0:0.88) &amp;amp; (t0:0.90) &amp;amp; (t0:0.92) \\
\dots &amp;amp; \dots  &amp;amp; \dots  &amp;amp; \dots \\
t16 &amp;amp; (t0:0.90) &amp;amp; (t0:0.93) &amp;amp; (t0:0.94) \\
t17 &amp;amp; (t0:0.90) &amp;amp; (t0:0.93) &amp;amp; (t0:0.94) \\
\end{Vmatrix}\]

&lt;p&gt;上面举例的情况是角色在跑动时跳跃，是适合左脚起跳还是右脚起跳。判断方法就是把当前帧的这一行取出来，找到相似度最大的那一列，列名就是最符合的衔接动作。&lt;/p&gt;

&lt;p&gt;我们还可以跟上面的功能组合起来，在起跳阶段使用衔接相似矩阵找最合适动作，落地阶段在融合相似矩阵里找到最佳融合帧进行融合。&lt;/p&gt;

&lt;p&gt;但是这样还不够完美，因为这个相似筛选都是基于静态的位置相似，没用运动趋势信息，所以得到的衔接或融合结果可能是违反上一次运动趋势的。&lt;/p&gt;

&lt;h2 id=&quot;把运动趋势考虑进来&quot;&gt;把运动趋势考虑进来&lt;/h2&gt;

&lt;p&gt;假设使用余弦相似，我们在制作衔接相似矩阵时就连续计算多个相邻帧的平均值作为相似：&lt;/p&gt;

\[Cos(Mi_{[m,m+n)},Mj_{[0,n)}) = \frac { \sum_{k=0}^n{Cos(Mi_{m+k}, Mj_k)}} {n}\]

&lt;p&gt;这样计算的相似性时，一帧暂时的极大相似就被连续的运动趋势抹平了。&lt;/p&gt;

&lt;p&gt;接下来在筛选阶段，我们连续取当前帧和未来相邻的多个帧数据，也就是相似矩阵里的多行。再比较出最大相似的行和列。比如:&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;当前动作执行到了run动作的第t10帧，执行起跳命令&lt;/li&gt;
  &lt;li&gt;在衔接相似矩阵里连续筛选了5行数据，比如t10-t14&lt;/li&gt;
  &lt;li&gt;找到相似值最大的元素&lt;/li&gt;
  &lt;li&gt;假设位于t13行，left jump列。&lt;/li&gt;
  &lt;li&gt;得到的最佳帧和当前帧还差3帧，那么就延迟3帧播放left jump动作。&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;看一下效果：&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;http://www.lpq.design/images/clip_frame_similar/run_to_jump.gif&quot; alt=&quot;image&quot; /&gt;&lt;/p&gt;

&lt;h2 id=&quot;性能分析和展望&quot;&gt;性能分析和展望&lt;/h2&gt;

&lt;p&gt;其实方案到这里和丐版的motion match还相差甚远，原因是前面的讨论都是基于静态分析的几个动作，而真正游戏运行时会有很多半身融合或者IK影响的姿态问题。&lt;/p&gt;

&lt;h3 id=&quot;空间时间复杂度&quot;&gt;空间时间复杂度&lt;/h3&gt;

&lt;p&gt;假设放弃实时计算相似性，先分析一下静态的相似匹配的空间和时间复杂度。&lt;/p&gt;

&lt;p&gt;融合相似矩阵的空间开销是n个二维表，n表示非loop动作数量，每个二维表里有m列，m表示loop动作的数量。运行时的查找开销是常数O1的。&lt;/p&gt;

&lt;p&gt;衔接相似矩阵的空间开销是m个表，n个列，运行时的查找开销是线性On的。&lt;/p&gt;

&lt;p&gt;而最重要的相似矩阵的计算是离线的，可以开发时统一生成。&lt;/p&gt;

&lt;h3 id=&quot;展望&quot;&gt;展望&lt;/h3&gt;

&lt;p&gt;面对质量要求越来越高，动作数量越来越多的需求下，这套朴素简单的相似性匹配逻辑能提供相对自动的融合和衔接方案，减少动作融合中的不和谐表现。&lt;/p&gt;

&lt;p&gt;目前的小样适用于其他全身动作到loop全身动作的融合和loop动作到其他动作的衔接匹配。于此同时还可以指定当前动作的后摇区间，让目标loop动作提前结束当前动作以最佳匹配融合效果。&lt;/p&gt;

&lt;p&gt;未来也行可以通过修改筛选逻辑，针对性的分多层加权判断相似性来做到半身融合的匹配。&lt;/p&gt;

&lt;p&gt;其实这套方法可以不在游戏运行的融合中使用，也可以在验收动作时，单独求出两个动作每一帧的相似性，以此来评判动作本身衔接融合有没有问题。&lt;/p&gt;
</description>
        <pubDate>Wed, 23 Jun 2021 00:00:00 +0000</pubDate>
        <link>http://www.lpq.design/2021/06/clip_frame_similar/</link>
        <guid isPermaLink="true">http://www.lpq.design/2021/06/clip_frame_similar/</guid>
        
        <category>Animation</category>
        
        <category>Unity</category>
        
        
      </item>
    
      <item>
        <title>子弹、手雷、投掷物系统</title>
        <description>&lt;h2 id=&quot;背景与需求&quot;&gt;背景与需求&lt;/h2&gt;

&lt;p&gt;作为射击游戏里的核心功能，子弹要承担很多重要表现和逻辑判定。这里的子弹是游戏里广义上所有具有飞行和物理碰撞判定的功能，同时也把激光当成一个速度无限大的连续多发的特殊子弹。&lt;/p&gt;

&lt;p&gt;当前设计里除了对子弹提出了多种运行轨迹的要求，还要尽可能模拟相对符合直觉的物理表现以及碰撞反弹效果。尤其是引入空气阻力的概念，给玩家制造一种子弹飞行远处下坠感强烈而近处射击相对平直易于瞄准的手感。&lt;/p&gt;

&lt;p&gt;最后多玩家多怪物在游戏过程中随时会发射大量子弹，因此子弹系统的性能也是一项必要的考虑内容。&lt;/p&gt;

&lt;h2 id=&quot;方案与选型&quot;&gt;方案与选型&lt;/h2&gt;

&lt;p&gt;当前项目使用Unity的原生物理引擎，检测碰撞一般来说OnCollision，OnTrigger和RayCast都可以实现。&lt;/p&gt;

&lt;p&gt;另一方面，逻辑的实现可以放到各个子弹上，也可以把所有子弹统一更新，光复杂度看起来都区别不大。&lt;/p&gt;

&lt;p&gt;而具体怎么选定，不如先就一些逻辑和性能上的限定条件进行讨论，再选出最合适的方法。&lt;/p&gt;

&lt;h3 id=&quot;阻挡与穿透&quot;&gt;阻挡与穿透&lt;/h3&gt;

&lt;p&gt;最朴素的实现就是子弹遇到Collider就停止，但是这样不能满足具体的业务需求：友军不阻挡、子弹可以多次穿透伤害等。因此首先就排除了OnCollision方法。&lt;/p&gt;

&lt;p&gt;穿透逻辑比较多样，所以这块设计时先确认拆分逻辑：主要的运动和检测这样性能消耗比较大、逻辑比较固定的部分放到C#里，穿透、命中等筛选逻辑和特效播放放置到脚本里方便日后同业务调整而热更新。&lt;/p&gt;

&lt;h3 id=&quot;高速运动的物体物理检测失效&quot;&gt;高速运动的物体物理检测失效&lt;/h3&gt;

&lt;p&gt;Unity物理有个特性，默认的Rigidbody的CollisionDetection不能检测到高速的运动碰撞，而&lt;a href=&quot;https://docs.unity3d.com/Manual/ContinuousCollisionDetection.html&quot;&gt;选择CCD&lt;/a&gt;的两个选项又有细微差别：&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;ContinuousDynamic可以很好的检测运动方向的碰撞，但是对旋转造成的碰撞则无法检测。&lt;/li&gt;
  &lt;li&gt;ContinuousSpeculative可以很好的检测高速旋转过程中的碰撞，但是运动方向会受到算法导致的碰撞角度偏差。&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;对于子弹逻辑上的述求，使用ContinuousDynamic就可以满足高速检测了。但是这里又有一个业务上的难点：业务上想实现相对多样（无重力的、有重力的、有阻力的、自动锁敌的）和规则相对自由的飞行轨迹，因此使用位置计算的算法比使用速度计算的算法相对简单，所以不太想用Rigidbody的速度来驱动子弹运动。&lt;/p&gt;

&lt;p&gt;比如自动锁敌的追踪子弹，使用位置计算方法如下：&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;var pos = Vector3.MoveTowards(transform.position, target.position, velocity * dt);
transform.position = pos;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;而使用速度计算的话还要多一些速度计算的步骤，虽然麻烦，但还没有到舍弃RigidBody的程度：&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;var pos = Vector3.MoveTowards(transform.position, target.position, velocity * dt);
rigid_body = transform.GetComponent&amp;lt;RigidBody&amp;gt;();
rigid_body.velocity = (pos - transform.position) / dt
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;子弹一定需要gameobject么&quot;&gt;子弹一定需要GameObject么&lt;/h3&gt;

&lt;p&gt;通常做物理表现时都是模型GameObject上挂个RigidBody和Collider来做，就算有些业务不想表现子弹飞行过程的话，也会用一个空的看不见的模型来实现。&lt;/p&gt;

&lt;p&gt;但是如果限定子弹一定要有GameObject的话，那么加载问题就不得不也考虑进来：发射一颗子弹后，逻辑上要不要等待异步加载子弹的过程的时间。&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;如果等待，那么相对于第一发子弹会发生效果滞后。&lt;/li&gt;
  &lt;li&gt;如果不等待，那么就要为加载过程中再写一个特殊逻辑。&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;再加上一小节的讨论内容，如果子弹的碰撞检测舍弃OnTrigger，使用RayCast来检测，那么逻辑反而相对简单了一些：&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;因为高速运动的子弹变成每帧静态的线段检测，所以解决了高速穿墙的问题。速度快的子弹只比速度慢的子弹线段长而已。&lt;/li&gt;
  &lt;li&gt;因为使用RayCast的线段检测，所以子弹有没有GameObject变的不重要了。
    &lt;ol&gt;
      &lt;li&gt;可以找个地方存放上一帧子弹的位置和速度，直接通过逻辑算出下一帧位置拉射线就行。&lt;/li&gt;
      &lt;li&gt;看不见弹道的子弹直接不加载模型，更省资源。&lt;/li&gt;
      &lt;li&gt;看的见的子弹就正常异步加载，没加载上来的时候只是看不见，不影响正常的物理检测逻辑。&lt;/li&gt;
    &lt;/ol&gt;
  &lt;/li&gt;
  &lt;li&gt;可以分别实现有碰撞半径的子弹（SphereCast）和无半径的子弹（RayCast）。&lt;/li&gt;
  &lt;li&gt;激光和光剑可以有条件的抽象成距离有限、速度无限大的子弹。
    &lt;ul&gt;
      &lt;li&gt;光剑挥舞的过程，如果光剑半径足够小或者转动足够快，那么就会遗漏检测。&lt;/li&gt;
      &lt;li&gt;如果使用collider和ContinuousSpeculative的方式则可以解决这个问题。&lt;/li&gt;
      &lt;li&gt;尽管如此，这里考虑了一下业务场景，通过约束转动速度，激光半径和命中距离来解决问题，不再引入新的检测模式。&lt;/li&gt;
    &lt;/ul&gt;
  &lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;img src=&quot;http://www.lpq.design/images/bullet_system/laster_missing.png&quot; alt=&quot;激光夹角示意&quot; /&gt;&lt;/p&gt;

&lt;p&gt;最后，子弹的逻辑不做在各自GameObject上的另一个原因是为了增加子弹系统的运算性能：&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;一个Update循环n次的计算性能表现要远远好于n个Update执行一次。&lt;/li&gt;
  &lt;li&gt;如果子弹数据使用结构体数组，那么这样还能利用内存访问的局部性。&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;fixedupdate还是update&quot;&gt;FixedUpdate还是Update&lt;/h3&gt;

&lt;p&gt;常规的物理功能都会放到FixedUpdate里，简单来说Update的帧率和间隔不稳定的，因为多数物理过程都是对运动速度积分来求运动轨迹，所以必须使用FixedUpdate来提供一个稳定的dt来求解稳定的积分结果。&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;http://www.lpq.design/images/bullet_system/fixedupdate_vs_update.png&quot; alt=&quot;两种Update的积分示意图&quot; /&gt;&lt;/p&gt;

&lt;p&gt;但是，不稳定的dt只对非线性的曲线积分有影响（变加速运动），而自由落体运动是线性的匀加速运动，其实不稳定dt不会产生误差。&lt;/p&gt;

&lt;p&gt;一般大家写路程计算的时候习惯性的：&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;var velocity = v0 + gravity * dt;
var dis = velocity * dt;
v0 = velocity;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;这样计算其实是错误的，使用FixedUpdate能表现正常只是凑巧错的均匀。正确的方法是使用平均速度来做计算：&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;var velocity = v0 + gravity * dt;
var average_v = (v0 + velocity) * 0.5f;
var dis = average_v * dt;
v0 = velocity;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;img src=&quot;http://www.lpq.design/images/bullet_system/average_v.png&quot; alt=&quot;两种Update的积分示意图&quot; /&gt;&lt;/p&gt;

&lt;p&gt;因此对于匀速运动和匀加速运动来说，使用Update并没有什么问题。从而避免使用FixedUpdate以节省计算性能：当发生卡顿时，两帧Update之间会插入更多的FixedUpdate，如果FixedUpdate的计算负担繁重，那么这会进一步增加卡顿表现。&lt;/p&gt;

&lt;p&gt;但是子弹运行轨迹里也并不是没有变加速运动，比如要引入子弹飞行阻力的话，那么子弹就会成为变加速运动。&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;引入子弹飞行阻力的原因是想表达高速子弹的前段飞行平直，后段飞行下坠明显的更进一步的拟真。&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;这里分两步处理这个问题：&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;现行的逻辑还是通过平均速度作为计算路径的参考，并合理估计误差，差距不大就忽略不计了。&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;如果未来不能接受这个误差，那么仅把有阻力影响的子弹单独处理，有两个改进方案：&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;把有阻力影响的子弹单独放到FixedUpdate里计算。&lt;/li&gt;
  &lt;li&gt;还是使用Update来计算，只是子弹额外记录一个remain_time字段，然后在Update里用固定的间隔执行一次或多次路径计算来模拟FixedUpdate的行为。&lt;/li&gt;
&lt;/ol&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;void Update() {
    var dt = GetTimeNow() - last_time;
    foreach(var bullet in bullets) {
        bullet.remain_time += dt;
        var n = (int)(bullet.remain_time / INTERVAL);
        bullet.remain_time -= n * INTERVAL;
        for(var i = 0; i &amp;lt; n; ++i) {
            SimulatePath(bullet);
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;layer不够用了怎么办&quot;&gt;Layer不够用了怎么办&lt;/h3&gt;

&lt;p&gt;写跟碰撞相关的逻辑经常会碰到Layer不够用的问题：&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;主角和其他角色不碰撞，子弹又要跟其他角色碰撞&lt;/li&gt;
  &lt;li&gt;有些空气墙跟角色碰撞，子弹又想穿过去。&lt;/li&gt;
  &lt;li&gt;为了优化性能和表现，和角色碰撞的collider是顶点少的粗模，而子弹命中的Collider又想是顶点多的高模。&lt;/li&gt;
  &lt;li&gt;还有为了其他各种业务使用的特殊层级。&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;为了节省Layer资源，这里子弹其实理论上不需要额外的层来处理阻挡或镂空逻辑，而是给具体的单位上挂个新的mask标记组件来表征业务逻辑，拓展Layer以外的数量。&lt;/p&gt;

&lt;h3 id=&quot;monobehavior还是static-class&quot;&gt;MonoBehavior还是static class&lt;/h3&gt;

&lt;p&gt;这里Mono表达的是Mono单例的意思，这俩种实现其实没有多大差别，但是还是推荐使用静态类来实现子弹系统的主循环。主要的原因有两个：&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;如果要用MonoBehavior，那么还要再指定Script Execution Order。直接在代码逻辑不容易判定于其他系统的执行的先后次序。&lt;/li&gt;
  &lt;li&gt;MonoBehavior单例在关闭游戏时总会发生先后交错释放导致再激活单例实例而报错的情况。&lt;/li&gt;
&lt;/ol&gt;

&lt;h2 id=&quot;运动&quot;&gt;运动&lt;/h2&gt;

&lt;h3 id=&quot;直线和抛物线运动&quot;&gt;直线和抛物线运动&lt;/h3&gt;

&lt;p&gt;除了匀速直线运动和理想抛物线运动以外，带阻力的直线运动和带阻力的抛物线运动都参考&lt;a href=&quot;https://zhuanlan.zhihu.com/p/124970286&quot;&gt;知乎-物理学导论-Projectiles&lt;/a&gt;的文章。为简化计算量，这里采用的是线性阻力计算。&lt;/p&gt;

&lt;p&gt;有阻力影响的水平速度分量：&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;var v1 = v0 * Mathf.Exp(-damping * dt);
var distance = (v1 + v0) * 0.5f * dt;
v0 = v1;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;如果一个子弹有阻力而且还可以动态的被额外的逻辑影响的话，那么可以实现黑客帝国中子弹悬停的效果。&lt;/em&gt; :)&lt;/p&gt;

&lt;p&gt;有阻力和重力影响的垂直速度分量：&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;var vter = -g / damping;
var v1 = vter + (v0 - vter) * Mathf.Exp(-damping * dt);
var distance = (v1 + v0) * 0.5f * dt;
v0 = v1;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;img src=&quot;http://www.lpq.design/images/bullet_system/damping_vs_gravity.png&quot; alt=&quot;阻力抛物线&quot; /&gt;&lt;/p&gt;

&lt;p&gt;由上图可以看到带阻力子弹的轨迹在飞行的前段其实差别不大，但是在飞行后段会很快下坠。阻力特性用来给玩家感受子弹在远处更强烈的下坠，和近处射击手感平直更容易瞄准。&lt;/p&gt;

&lt;h3 id=&quot;拟合的曲线运动&quot;&gt;拟合的曲线运动&lt;/h3&gt;

&lt;p&gt;怪物通常也会发射一个曲线子弹，这个曲线子弹如果也使用抛物线的话，那么发射角度、命中点比较难以计算（求解弹道方程，解还不唯一）。这里使用了两个正弦曲线来拼接一个曲线弹道。这么做的原因除了方便计算外，另一个是怪物可能是在室内发射的曲线子弹，使用两个正弦来拼接曲线容易得到一个在接缝处导数相等的曲线，又方便配置弹道的最大高度，避免打到房顶上。&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;var time_factor = (last_update_time - start_time) / (life_time - start_time);
var ratio = Mathf.Clamp((0.5f - time_factor) * 2, -2, 1);

var caster_pos = caster.transform.position;
var target_pos = target.transform.position;
var height = Mathf.Max(caster_pos.y, target_pos.y) + cfg_delta_height;
var dis = (target_pos - caster_pos).normalized * velocity * dt;
var y = 0f;
if (ratio &amp;gt; 0) {
    y = (height - caster_pos.y) * Mathf.Sin(Mathf.Deg2Rad * 90 * (1 - ratio)) + caster_pos.y;
} else {
    y = (height - target_pos.y) * Mathf.Sin(Mathf.Deg2Rad * 90 * (1 + ratio)) + target_pos.y;
}
var point = new Vector3(dis.x, y - pos.y, dis.z);
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;img src=&quot;http://www.lpq.design/images/bullet_system/sin_sin.png&quot; alt=&quot;正弦拼接示意&quot; /&gt;&lt;/p&gt;

&lt;h3 id=&quot;碰撞与反弹&quot;&gt;碰撞与反弹&lt;/h3&gt;

&lt;p&gt;如果子弹碰撞以后要反弹，而不是直接销毁，那么逻辑上有一些不得不注意的细节：&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;如果是有半径的子弹碰到Collider，那么返回的&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;raycastHit.point&lt;/code&gt;并不是子弹要停留的点，而是撞击点。真正子弹停留的点是：&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;point = raycastHit.point + raycastHit.normal * bullet.radius&lt;/code&gt;。即，向着法线移动一个半径的距离的点才是子弹发生碰撞时的位置。&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;img src=&quot;http://www.lpq.design/images/bullet_system/collision.png&quot; alt=&quot;碰撞点和半径关系&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;如果子弹不是匀速运动的，那么碰撞了以后，在撞击点计算出射速度时不能直接使用轨迹计算得出的终速度，而是要用真实路程反向矫正撞击点的终速度。匀加匀减速运动的撞击拟合速度计算如下：&lt;/li&gt;
&lt;/ul&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;// p0: 当帧初始位置; p1:当帧结束位置; hit_pos:撞击位置
var ratio = (hit_pos - p0).magnitude / (p1 - p0).magnitude;
var velocity = Vector3.Lerp(v0, v1, ratio);
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;ul&gt;
  &lt;li&gt;同样，当撞击时，子弹使用的时间也不是dt了，而是需要使用撞击时的路程除以速度以矫正：&lt;/li&gt;
&lt;/ul&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;var real_time = (hit_pos - p0).magnitude / velocity.magnitude;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;ul&gt;
  &lt;li&gt;由上一步计算得出，没有消耗完的时间也不能丢弃，要叠加到下次运动模拟中。&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;remain_time = dt - real_time;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;这里有个表现特性，如果当帧通过再迭代执行一次路径模拟把remain_time消耗完，那么子弹在碰撞时的速度表现是相对稳定的，但是玩家有可能看不到碰撞点只能看到子弹反弹的结果。如果把remain_time积攒到下一帧再运算时，玩家会清晰的看到子弹在哪里碰撞（因为子弹在这个位置多停留了），但是下一帧的出射速度会因时间压缩而增加：&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;v = (dt + remain_time) / dt * v&lt;/code&gt;&lt;/em&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;如果子弹在斜波连续碰撞（特别快速的连续碰撞的表现是贴地翻滚），那么要额外在碰撞当帧给子弹增加一个重力影响在斜坡上的速度分量，用以增强斜坡滚动加速能力：&lt;/li&gt;
&lt;/ul&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;bullet.velocity += Vector3.Reflect(Vector3.down, raycastHit.normal) * gravity * dt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;ul&gt;
  &lt;li&gt;如果子弹在碰撞时还要表现翻滚和旋转，那么需要用两次实际撞击点的距离和子弹半径算出翻滚角度。&lt;/li&gt;
&lt;/ul&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;var s = Vector3.Distance(p0, p1);
var alpha = s / (Mathf.PI * 2 * bullet.radius) * 360;
var rot = Quaternion.AngleAxis(alpha, Vector3.Cross(normal, bullet.forward));
bullet.localRotation = rot * bullet.localRotation;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;img src=&quot;http://www.lpq.design/images/bullet_system/roll.gif&quot; alt=&quot;翻滚示意&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;碰撞以后的动能损失&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;因为模拟的过程中其实没有处理质量影响，所以这里的动能损失其实速损失。由一个-1到1的参数控制：&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;velocity *= (1 - factor);&lt;/code&gt;。当配置为1时，碰撞完全损失速度，表现是粘到墙上；当配置是0时，是完全弹性碰撞；反弹的出射速度等于入射速度。当配置小于0时是增强弹射速度，表现是越弹越快。搭配使用增强弹射和命中次数可以实现某些游戏里的弹射子弹效果。&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;碰撞位置的同步。&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;当前没有同步碰撞的位置，只是同步了子弹的出射位置和方向。考虑是因为大部分碰撞是跟静态场景碰撞，所以根据上述章节的讨论，各个端分别模拟的结果其实差别不是很大。未来如果强制同步碰撞位置，那么需要增加一个等待/记录的逻辑，当子弹碰撞以后原地等待或使用已经收到的新的同步速度和方向(这样的表现等同于PUBG端游，网络卡时，手雷停到碰撞点等同步)。&lt;/p&gt;

&lt;h3 id=&quot;激光&quot;&gt;激光&lt;/h3&gt;

&lt;p&gt;如前文介绍，激光是一个特化版子弹，主要需要配置的参数是激光半径、最大出射距离、生命时间和最小伤害时间。在实现激光时需要额外记录的上一次的发射位置和方向（Ray）和当前位置和方向（Ray），最后用&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;var n = Mathf.Ceil(dt / interval);&lt;/code&gt;得出需要发射的子弹数插值依次计算。&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;if(bullet.life_time &amp;gt; now) {
    for(var i = 0; i &amp;lt; n; ++i) {
        var factor = ((float)i) / ray_count;
        var pos = Vector3.Lerp(old_ray.origin, cur_ray.origin, factor);
        var dir = Vector3.Slerp(old_ray.direction, cur_ray.direction, factor);
        BulletHitLogic(pos, dir, raduis, length)
    }
} else {
    bullet.Destory();
}
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;img src=&quot;http://www.lpq.design/images/bullet_system/laser.gif&quot; alt=&quot;激光&quot; /&gt;&lt;/p&gt;

&lt;h3 id=&quot;还是超大地图范围的影响&quot;&gt;还是超大地图范围的影响&lt;/h3&gt;

&lt;p&gt;因为地块移动，所以子弹在计算位置时要么直接使用GlobalPos来计算，要么就使用WorldPos来计算，然后当地块发生移动的时候统一重新校准所有还在飞行中的子弹。但是这样只能保证子弹的逻辑位置是正确的，如果子弹的模型上由world类型的拖尾特效，那么这个特效会因为地块的突然移动而出现表现异常。&lt;/p&gt;

&lt;p&gt;另一个是，如果是直接使用GlobalPos来计算，那么子弹位置中使用UnityEngine.Vector3记录的精度问题又不得不考虑进来。如果地图边界小于8km的话，那么可以认为GlobalPos最少有0.01的精度，在位置设置中额外矫正一下其实问题也不大。&lt;/p&gt;

&lt;h3 id=&quot;参考系影响&quot;&gt;参考系影响&lt;/h3&gt;

&lt;p&gt;这块内容其实在&lt;a href=&quot;&quot;&gt;惯性参考系&lt;/a&gt;中有讨论，不做额外说明。&lt;/p&gt;

&lt;h2 id=&quot;命中表现&quot;&gt;命中表现&lt;/h2&gt;

&lt;p&gt;命中表现纯粹是一个堆工作量的内容，堆的越多，表现就越丰富。设计一个合理的表现配置逻辑有助于花少量工作来拓展表现能力。&lt;/p&gt;

&lt;p&gt;这里对命中表现做两种分类，一种是固定表现特效，一种是依据材质的差异表现特效。固定表现的特效很简单，条件到了播放就行。材质特效在配置上有过争议，为了表现不同枪支打到不同材质上播放不同的特效，早先配置的行索引是枪械类型，列索引是当前游戏里的物理材质，逻辑按照当前枪械类型和当前命中的材质索引最终表现。后面考虑到材质拓展和地形层次（TerrainLayer）拓展的方便性，把材质配置为行索引，具体的子弹ID作为列索引而舍弃枪支类型（武器关联技能，技能关联子弹，子弹关联表现。用单线条的配置关联逻辑取代之前交叉的关配置关联逻辑）。调整后的配置逻辑可以非常简单的来丰富或裁剪材质命中表现，比如想要或关闭手雷打地上弹起尘土，打水面上溅起水花这样的表现，就修改对应材质两行数据中手雷子弹列的下材质特效就成。&lt;/p&gt;

&lt;p&gt;特效播放的逻辑里有一个需要在制作流程上就要明确的约定：美术制作前就要明确当前特效的参考轴向是什么。比如一个贴在地上的弹孔特效就要是参考具体地面&lt;strong&gt;法线方向&lt;/strong&gt;；一个空中爆炸榴弹的烟火特效就要是参考榴弹&lt;strong&gt;前进方向&lt;/strong&gt;；未来可能还有有一些水面特效只能参考空间坐标下的&lt;strong&gt;Y方向&lt;/strong&gt;。资源制作明确轴向以后，配置时也要给逻辑指明使用哪种方式来播放，不然就会出现奇奇怪怪的特效偏转不正确现象。&lt;/p&gt;

&lt;p&gt;除了上述的空间绝对位置，一些命中特效要跟随命中目标移动。这里有三种跟随方式：&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;跟随并使用骨骼位置。（目标的特定位置播放特效）&lt;/li&gt;
  &lt;li&gt;跟随骨骼运动，并保持命中位置和骨骼之间的相对位置。（表现打到某具体位置，并跟着它动，怪物命中后的创伤、箭羽等）&lt;/li&gt;
  &lt;li&gt;跟随命中Collider运动，并保持命中Collider的相对位置。（跟上一条一样，只是表现更精细，比如打到门上后，门开了并带着弹痕移动）&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;未来还可以扩展一些水面、玻璃这样的可击碎表现。其中水面的实现相对简单，检测到碰撞直接播放材质特效就行；但是玻璃需要服务器（可同步的）或本地（离线种的表演单位）计时刷新击碎状态，不能连续不断的打碎。&lt;/p&gt;

&lt;h2 id=&quot;其他相关联的特性&quot;&gt;其他相关联的特性&lt;/h2&gt;

&lt;h3 id=&quot;怪物的瞄准ik&quot;&gt;怪物的瞄准IK&lt;/h3&gt;

&lt;p&gt;AimIK给主角使用相对简单，表现也正常，但是给怪物（炮塔、机器人）使用时，就会放大问题：IK启用和关闭时没有缓动；IK解算器在一些边界角度无法求解。考虑到怪物瞄准时需要转动的骨骼是非常有限的，所以这里造了一个分别限定水平和垂直旋转规则的简易IK的轮子：&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;http://www.lpq.design/images/bullet_system/aimik_vs_aimctrl.gif&quot; alt=&quot;IK改进&quot; /&gt;&lt;/p&gt;

&lt;h3 id=&quot;射线检查与子弹出射点&quot;&gt;射线检查与子弹出射点&lt;/h3&gt;

&lt;p&gt;TPS射击模式有个问题是当角色顶着墙或者怪物射击时，枪口是跟对应Collider是相交的，这样导致：&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;双射线（摄像机到目标点，目标点到子弹出射点）中第二条射线已经在被检测的Collider内部，因此检测失效无法击中目标。&lt;/li&gt;
  &lt;li&gt;子弹特效的出射方向大幅偏离枪口方向，跟直觉理解有差别。&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;另一个现象是，如果有能量防护罩玩法，角色跟防护罩没有碰撞，但是阻挡子弹，玩家穿过或半穿过防护罩，但是摄像机没有，那么双射线仍然会选错目标点（可能会选到防护罩上，而不是角色前方）。&lt;/p&gt;

&lt;p&gt;完全解决这个问题需要细节调整：&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;双射线的筛选规则中需要指定当第一条射线距离目标点多近时第二条射线失效的容错距离。当目标已经贴脸时就不启用枪口检查了。&lt;/li&gt;
  &lt;li&gt;双射线的筛选规则，两个射线的筛选规则相互独立。这样可以配置出两个不一样的目标选中规则。比如摄像机不筛选防护罩，但是枪口射线选中防护罩。如果角色和摄像机都没有穿过防护罩，那么相机射线选中的目标在第二次枪口射线选中时被阻挡；如果角色穿过但是相机没有穿过，那么枪口射线能正常选中目标，符合直觉体验。&lt;/li&gt;
  &lt;li&gt;子弹的出射挂点不使用枪口位置，而是使用靠近枪托的位置。这样可以最大限度保证贴墙射击时，子弹的出射方向不会偏差太大。（PUBG和全境封锁2是这么做的）&lt;/li&gt;
&lt;/ol&gt;

&lt;h2 id=&quot;结&quot;&gt;结&lt;/h2&gt;

&lt;p&gt;现在的设计里其实还存在单位运动导致物理检测失效的情况，只不过不是子弹高速运动导致的失效，而是单位运动速度大于或接近子弹运动速度时发生（当前没有观测到发生，通过逻辑推断出会存在这样的Bug）。原因也同一开始CCD里讨论的一样。这里暂时先放过这个问题，要具体看游戏里出现的频率和情况，如果部分条件下不能接受，那么还可以选择性的给有限的子弹上加上RigidBody和CCD检测来解决问题。&lt;/p&gt;

&lt;p&gt;最后，子弹系统主体上是个不太麻烦的内容，但是设计一个相对容易扩展，能在后期不断投入工作量就能丰富细节的系统也是一个有挑战的内容。&lt;/p&gt;
</description>
        <pubDate>Sun, 07 Feb 2021 00:00:00 +0000</pubDate>
        <link>http://www.lpq.design/2021/02/bullet_system/</link>
        <guid isPermaLink="true">http://www.lpq.design/2021/02/bullet_system/</guid>
        
        <category>GamePlay</category>
        
        <category>Unity</category>
        
        <category>Physics</category>
        
        
      </item>
    
      <item>
        <title>射击MMO手游里的惯性参考系</title>
        <description>&lt;p&gt;本文主要讨论在射击游戏里实现一个具有惯性参考系的位置、射击、技能、特效和IK功能的同步系统&lt;/p&gt;

&lt;!--more--&gt;

&lt;h2 id=&quot;需求&quot;&gt;需求&lt;/h2&gt;

&lt;p&gt;我们要在大地图里做一些可移动的平板、电梯、火车，多个玩家和怪物可以在这些平台上自由移动、战斗、射击战斗以及上下平台。&lt;/p&gt;

&lt;h3 id=&quot;背景&quot;&gt;背景&lt;/h3&gt;

&lt;p&gt;项目是mmo类的枪械射击手游，使用状态同步，原生Unity物理引擎，位置和方向数据使用定点数，但没有保证完全恢复。&lt;/p&gt;

&lt;h2 id=&quot;一些朴素的想法&quot;&gt;一些朴素的想法&lt;/h2&gt;

&lt;h3 id=&quot;纯物理引擎托管&quot;&gt;纯物理引擎托管&lt;/h3&gt;

&lt;p&gt;使用原生物理引擎，刚体加物理材质的动静摩檫力。落到平板上的单位被物理引擎带着走。一般的纯客户端本地demo这么做可以在极小的工作量下达到非常好的效果，但是对于同步其他玩家或者多端同步怪物表现则有非常大的困难。另一方面，单纯的使用物理引擎，不容易实现一些状态、技能、子弹的位移和业务逻辑。
比如根据配置需要在板上切换是相对于载具空间还是相对于大世界空间的跳跃逻辑。&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;在惯性参考系里竖直跳跃是落在相对于惯性参考系的原地还是落在相对于世界的起跳点
    &lt;ul&gt;
      &lt;li&gt;在行进的火车车厢里竖直跳跃，要落到原地&lt;/li&gt;
      &lt;li&gt;在行进的火车车顶竖直跳跃，不会落到原地&lt;/li&gt;
    &lt;/ul&gt;
  &lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;如果不干预物理引擎，那么应用场景很有限；如果要处理这些细节，那么就要写毛毛多特例分支来单独搞，甚至还搞不定。&lt;/p&gt;

&lt;h3 id=&quot;使用hierarchy挂接&quot;&gt;使用Hierarchy挂接&lt;/h3&gt;

&lt;p&gt;直接把在平板上的单位挂接到平板上，利用Unity的Hierarchy的父子关系实现相对位移等运动逻辑。这么做的好处是引擎帮忙计算相对复杂的Transform相关数据，快速实现相对运动，也能相对灵活的切换不同参考系&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;trasform.SetParent(inertial_system)&lt;/code&gt;。&lt;/p&gt;

&lt;p&gt;但是这么做同样存在一些相对棘手的问题(以下问题同样适用于装备、特效、乘骑等的挂接逻辑)：&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;同步的逻辑严重依赖渲染逻辑(GameObject–我的认知是GameObject主要是用来做渲染逻辑，而不是主要用来做GamePlay逻辑)。&lt;/li&gt;
  &lt;li&gt;父节点在Active或者Deactive时不能对子节点做Attach和Detach操作(前项目为了能Att\Detach，做了大量delay一帧的操作，留下很多隐患)&lt;/li&gt;
  &lt;li&gt;显隐平板时会波及到平板上的单位(由具体业务来决定要不要显隐)&lt;/li&gt;
  &lt;li&gt;平板和平板上的单位先后进出视野时不好处理挂接和位置关系(额外要求服务器处理协议发送顺序与视野逻辑)&lt;/li&gt;
  &lt;li&gt;如果通过对象池来优化单位创建与销毁，那么正确的拆解挂载也是一个不小的挑战&lt;/li&gt;
  &lt;li&gt;平级的多个单位的更新次序不好确定&lt;/li&gt;
&lt;/ol&gt;

&lt;h3 id=&quot;构造一套通用的逻辑上的hierarchy结构&quot;&gt;构造一套通用的逻辑上的Hierarchy结构&lt;/h3&gt;

&lt;p&gt;既然不能直接用原生的，那么就造轮子。先定义一个root，然后平板是root的逻辑子节点，平板上的单位是平板的逻辑子节点。参考Unity实现，只记录local position / rotation，最后每帧把逻辑位置换算成world position再设置到Transform上。
这个方案主要的问题就是性能效率了。&lt;/p&gt;

&lt;h2 id=&quot;基础分析&quot;&gt;基础分析&lt;/h2&gt;

&lt;h3 id=&quot;跟位置密切相关的逻辑&quot;&gt;跟位置密切相关的逻辑&lt;/h3&gt;

&lt;p&gt;比起常规MMO，IK对射击游戏的逻辑影响更深。比如之前MMO里使用IK只做一个表现适配：播特定动画时把头、手、脚对应到差不多的位置和方向上。而射击游戏至少要依赖IK把枪、手、肘对应到瞄准点方向，然后才能触发技能和射线检查。技能、子弹激发时再最后把特效位置对应到各个挂点上。现在再引入惯性参考系，就又增加了位置逻辑的复杂度。&lt;/p&gt;

&lt;p&gt;除了常规技能逻辑和效果的播放，另一个惯性参考系对射击游戏的影响是子弹的出射速度，即出射速度是按照速度叠加上参考系运动还是子弹在参考系里使用local坐标运动，或者直接什么都不做。什么都不做的方案对于子弹速度远远大于参考系速度的情况下是看不出明显问题的，只有两者速度相近时才会发现问题，比如逆着火车进行方向扔一颗出手速度差不多的手雷有可能直接糊在投掷者脸上。&lt;/p&gt;

&lt;p&gt;另一个不容易被事先想到的例子是针对粒子特效的影响。粒子特效在制作时可以选择local和world效果。两者的差别是制作一根棍子一样的刚性效果，还是制作一个喷射的火舌或者可以随目标运动而自由飘散的烟雾。引入惯性参考系后local特效不会有任何影响，因为它本身就是局部的。但是world特效就难处理了，如果在行进的火车上摆一个跟随火车移动的火把，火焰会像被风吹了一样向后摆动。针对这个效果的建议是要在制作时就主动思考这个特效是否可以合理的应用到惯性系里（比如敞篷的火车上的world特效的火把被风吹动是合理的，在车厢里的火把要做成local的特效）。&lt;/p&gt;

&lt;h3 id=&quot;特效跟随&quot;&gt;特效跟随&lt;/h3&gt;

&lt;p&gt;因为在&lt;a href=&quot;#1.2.2&quot;&gt;使用Hierarchy挂接&lt;/a&gt;里分析的问题，所以特效主动写一个跟随系统在LateUpdate里同步位置。这么做的另一个好处是特效不用考虑是挂接到世界里的单位上还是挂在参考系里的单位上的，只要特效跟随系统工作前，其他位置逻辑都处理正确，那么它的效果就是正常的。&lt;/p&gt;

&lt;h3 id=&quot;单位和功能的更新次序&quot;&gt;单位和功能的更新次序&lt;/h3&gt;

&lt;p&gt;确定一个相对合理的更新次序有利于解决一部分上述提到的困难，另一个就是明确管理Update和LateUpdate的执行细节，避免出现意外问题。&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;基础核心逻辑 …&lt;/li&gt;
  &lt;li&gt;消息和网络协议逻辑(服务器位置同步)&lt;/li&gt;
  &lt;li&gt;default(单位位置更新)&lt;/li&gt;
  &lt;li&gt;一般业务逻辑.Update&lt;/li&gt;
  &lt;li&gt;惯性参考系同步.Update&lt;/li&gt;
  &lt;li&gt;头顶信息跟随系统.Update(单位的不明显位置跟随显示)&lt;/li&gt;
  &lt;li&gt;Unity动画系统(骨骼动画)&lt;/li&gt;
  &lt;li&gt;IK系统.LateUpdate(骨骼姿态调整)&lt;/li&gt;
  &lt;li&gt;Lua主更新逻辑.LateUpdate(技能和子弹逻辑)&lt;/li&gt;
  &lt;li&gt;头顶信息跟随系统.LateUpdate(单位的明显的位置跟随显示)&lt;/li&gt;
  &lt;li&gt;特效跟随系统.LateUpdate&lt;/li&gt;
&lt;/ol&gt;

&lt;h3 id=&quot;性能问题&quot;&gt;性能问题&lt;/h3&gt;

&lt;p&gt;正如前文分析的因为不能完全都自己构造Hierarchy，又要使用相对位置来确保同步，所以折中只记录载具和载具上的单位，并限定有限层挂载，不无限兼容下去。&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;class Entity {
    public Vector3 local_pos;
    public Quaternion local_rot;
    public Transform transform;
}

class InertialFrame {
    Entity self;
    public void SyncAllChilden();
    List&amp;lt;Entity&amp;gt; eitites;
}

class InertialSystem {
    List&amp;lt;InertialFrame&amp;gt; inertial_frames_1;
    List&amp;lt;InertialFrame&amp;gt; inertial_frames_2;

    void Update() {
        foreach(var frame in inertial_frames_1) {
            frame.SyncAllChildren();
        }
        foreach(var frame in inertial_frames_2) {
            frame.SyncAllChildren();
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;这么处理的一个假设是场景中惯性参考系里的单位并不会很多，参考系里再嵌套一个参考系的情况又更少。大部分单位和第一级参考系通过业务逻辑驱动处理在世界中的位置或记录参考系中的相对位置，等惯性参考系系统更新时多算一次参考系里所有单位的位置。&lt;/p&gt;

&lt;h3 id=&quot;超大地图范围的影响&quot;&gt;超大地图范围的影响&lt;/h3&gt;

&lt;p&gt;因为浮点数精度限制，当在Unity里使用超大地图范围时，模型的骨骼动画会被精度问题影响而抽搐。
而具体到什么范围内就会看到抽搐，取决于具体模型的层级关系和模型动画内容。定性的来说就是播放动画的骨骼层级越深，那么就越容易碰到精度墙。
典型的float精度范围如下：&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;数值&lt;/th&gt;
      &lt;th&gt;最接近的两个浮点数的差值&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;1000&lt;/td&gt;
      &lt;td&gt;6.10352e-05&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;10000&lt;/td&gt;
      &lt;td&gt;0.000976562&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;100000&lt;/td&gt;
      &lt;td&gt;0.0078125&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;1000000&lt;/td&gt;
      &lt;td&gt;0.0625&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;使用地块平移技术可以解决这个精度问题，但是这又给业务逻辑带来了额外的负担。各个业务在使用实际位置时要先做一次转化。
如果我们把Unity实际使用的世界位置定义为WorldPos，逻辑上使用的（服务器使用的）位置定义为GlobalPos，在参考系子空间的位置定义为LocalPos，那么他们的转化关系如下：&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;GlobalPos = WorldPos + index * BlockSize;
// or
WorldPos = GlobalPos - index * BlockSize;

WorldPos = InertialFrame.WorldPos + InertialFrame.WorldRot * LocalPos
// or
LocalPos = Inverse(InertialFrame.WorldRot) * (WorldPos - InertialFrame.WorldPos)
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;需要注意的是在处理物理检测和修改Unity里Transform时，要都使用WorldPos。&lt;/p&gt;

&lt;h3 id=&quot;范围射线与技能筛选&quot;&gt;范围、射线与技能筛选&lt;/h3&gt;

&lt;p&gt;如果各个主要的模块与逻辑更新次序调整好的话，这块逻辑反而不用特殊处理天然就是正确的。
对于立即筛选的范围或者检测的射线，不管在不在参考系里，使用WorldPos都可以满足其筛选规则。
对于持续性技能的话，也可以变成每帧或每几帧立即检测来解决。&lt;/p&gt;

&lt;h2 id=&quot;实现细节&quot;&gt;实现细节&lt;/h2&gt;

&lt;h3 id=&quot;创建与同步&quot;&gt;创建与同步&lt;/h3&gt;

&lt;p&gt;服务器把惯性参考系等同于一般单位一样同步视野信息和初始位置信息。在惯性参考系进入世界的协议里携带所有乘客ID，单位进入世界时也携带所在惯性参考系的ID。这么做有助于异步、分步处理相互之间的挂接关系。如果单位位于参考系中，那么携带的位置信息就是相对与该参考系的本地位置（LocalPos and LocalRot）。反之单位位置为世界位置（WorldPos and WorldRot）。
主角在惯性参考系里上下线不仅要有本地位置，还要携带一个额外的世界位置，原因是一般逻辑里主角上线只有加载完地图才跟服务器拉取周围视野信息，而不拉取视野信息就不知道要加载哪一个地图地块。
同步其他单位的位置信息时都要决议当前单位是否处在参考系中。如果在参考系中，那么就要给参考系记录本地位置而不处理Transform；如果不在参考系中，那么就直接给Transform设置世界位置。
对于其他业务逻辑模块的要求是不能直接设置单位的Transform。如果必要，那么使用惯性参考系提供的位置接口。
单位的移动动画的播放速率也要变成使用本地位置变化的速度以匹配相对于参考下的正确表现。&lt;/p&gt;

&lt;h3 id=&quot;切换参考系&quot;&gt;切换参考系&lt;/h3&gt;

&lt;p&gt;前面讨论的都是单位在参考系不变化的情况，而真实复杂的情况是角色控制移动或跳跃到静止或运动的参考系中，或反之从参考系中出来，或从一个参考系运到到一个参考系中。
切换参考系主要遇到的难点是位置和运动信息的连续性。
比如单位从参考系A跳入参考系B中，遇到的问题有：&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;参考系A先于参考系B完成位置更新，单位脱离参
考系A完成位置转化，再进入参考系B后又会被计算一次移动。&lt;/li&gt;
  &lt;li&gt;参考系B先于参考系A完成位置更新，单位脱离参考系A不进行位置转化，进入参考系B后位置错误。&lt;/li&gt;
  &lt;li&gt;在参考系切换时，强行捋顺参考系的更新次序在逻辑上难度非常大。(如果使用Unity的Hierarchy来做相对位置就更难指定各个单位间更新次序)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;要保证切换参考系，单位不会发生瞬间位置拉扯的问题，最主要的一点就是利用了&lt;a href=&quot;#1.2.3&quot;&gt;单位和功能的更新次序&lt;/a&gt;和&lt;a href=&quot;#1.2.4&quot;&gt;性能问题&lt;/a&gt;里假定的约束条件：&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;世界里的参考系(第一级)的位置更新都先于所有在(第一级)参考系里的单位位置更新；&lt;/li&gt;
  &lt;li&gt;第二级参考系位置更新都先于第二级参考系里单位位置更新。&lt;/li&gt;
  &lt;li&gt;第二级参考系位置更新也要先于第一级参考系里的普通单位。&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;满足上述约束后，至少单位在运动时，各个参考系的位置都是确定的了。在切换发生时，还要把单位移动逻辑里记录的所有位置信息等价同步转化成新参考系里的位置信息。&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;function SwitchInertialFrame(local_pos, frame_1, frame_2)
    world_pos = frame_1.world_pos + frame_1.world_rot * local_pos
    local_pos = Inverse(frame_2.world_rot) * (world_pos - frame_2.world_pos)
    return local_pos
end
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;旋转也同样要转化。&lt;/p&gt;

&lt;h3 id=&quot;瞄准与ik&quot;&gt;瞄准与IK&lt;/h3&gt;

&lt;p&gt;射击游戏里同步单位的方向略微复杂：单位移动方向、单位面向、单位的枪械的朝向。在开始的设计里，移动和面向是分两条协议同步的，后面通过仔细分析发现，如果发生分包，两个协议间隔一些时间再收到，那么同步的面向就会发生错位。整理成一条的同步协议里至少要包含当前位置、移动方向、单位瞄准目标点、单位是否启用持枪IK。
当前位置和移动方向这俩个没什么问题，需要讨论的是单位瞄准目标点的设计。开始的设计是同步的单位的面向，但是分析之后发现就算把单位面向调整好，持枪的枪口方向仍然存在细节上的不正确。单位持枪其实是一个直角三角形的关系：角色的中轴线是直角点，角色面向是一条直角边，那么枪口方向就与三角形的斜边是一个方向，角色面向和枪口方向交汇的点是真实瞄准的点。
换句话来说，调整持枪方向，不仅要随瞄准目标高低调整持枪的俯仰，还要随瞄准目标距离的远近调整持枪的开合。因此通过瞄准点加当前位置两个信息就能传递完整的持枪方向和面向。
讨论回惯性参考系，如果单位在参考系中时，那么同步的位置信息和瞄准目标点就要使用参考系的本地坐标了。
最后，如果单位处于惯性参考系下，那么就要每帧重新使用本地坐标、方向、距离来计算出AimIK使用的世界坐标，然后传递给IK组件。
反之如果不在参考系中，那么就在接收协议时设置一次IK WorldPos就行。
至于同步标记启用持枪IK这个则单纯是业务需求：一些瞄准行为可能不是持枪瞄准，比如投掷手雷。&lt;/p&gt;

&lt;h3 id=&quot;空间判断与特效位置&quot;&gt;空间判断与特效位置&lt;/h3&gt;

&lt;p&gt;在运动的参考系中放置一个明确相对位置的特效跟随运动是容易的，但是如果先接收到一个要在某世界位置下创建一个特效的消息，然后再判断这个位置在参考系中就跟随运动就困难多了。
这里通过预先制作，在参考系模型里（比如火车）先挂一个或多个Collider Trigger来表明参考系的空间范围（车厢空间有多大）。
在游戏运行时，逻辑里通过Pos In Collider的方案来确认从属关系。
这么做带来的代价是相对低效一点，但是根据前面讨论的假设，这个问题并不是主要矛盾:&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;参考系同时进入视野的个数非常有限&lt;/li&gt;
  &lt;li&gt;只有在世界中具体位置直接放置的特效才进行检查&lt;/li&gt;
  &lt;li&gt;有挂接关系的特效被特效跟随系统处理了&lt;/li&gt;
&lt;/ol&gt;

&lt;h3 id=&quot;子弹逻辑与碰撞反弹&quot;&gt;子弹逻辑与碰撞反弹&lt;/h3&gt;

&lt;p&gt;射击游戏里出了持枪射击以外，另一个一个主要的元素可能就是有一个可以弹来弹去手雷了。同样，碰撞与弹射也可以朴素的想到使用Unity物理引擎来托管表现。但是这里因为同上文类似的原因以及处理复杂的穿透和碰撞逻辑，没有直接使用物理的碰撞OnCollide来做，而是使用射线检查来主动计算碰撞反射角和动能损失。&lt;/p&gt;

&lt;p&gt;在惯性参考系中发射子弹(手雷也是子弹的一种，只是速度慢)一般来说有三种情况：&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;像角色单位一样直接以从属关系使用子弹的配置速度和加速度计算本地坐标。&lt;/li&gt;
  &lt;li&gt;在子弹发射开始的时候直接把惯性系的速度折算进子弹的初速度里。&lt;/li&gt;
  &lt;li&gt;单独记录参考系速度，每帧计算完子弹路径再叠加单独的参考位移。&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;第一种情况相对来说局限大一点：如果像单位一样直接计算本地坐标，那么子弹可能频繁进出多个惯性参考系空间。子弹进入空间就立刻跟随参考系运动则违反直觉(路径轨迹立刻被扭曲)，如果不跟随运动那么每帧有多次无效的位置转化计算，不仅时性能上划不来，而且还在设计编写时变得复杂。&lt;/p&gt;

&lt;p&gt;第二种情况是最简单的实现方式，如果子弹发射时就在参考系里就把参考系的速度折算到子弹初速度里，其他时候都按照正常逻辑不用管，反之更不用特殊处理。如果时手雷，那就随着碰撞计算，当动能都损失完以后检查最后一次碰撞是不是在参考系上，如果时在参考系上，那么就把手雷从属于参考系就结束了。简单有效。&lt;/p&gt;

&lt;p&gt;但是，实际上采用了第三种实现形式。因为它可以现实一个我认为非常好的效果：手雷在切换参考系时，不立刻完全继承参考系速度而是跟随弹性系数逐渐加速到目标参考速度。这样看起来运动相对柔和一些。计算方法也不复杂：&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;// some bullet velocity and path logic

if(inertial_velocitys.TryGetValue(bullet.id, out var inertial_velocity)) {
    bullet.position += (inertial_velocity * dt);
}

// bullet elastic logic

if(InertialSystem.IsInertialFrame(collide_id, out var frame)) {
    if(!inertial_velocitys.TryGetValue(bullet.id, out var v)) {
        v = Vector3.zero;
    }
    inertial_velocitys[bullet.id] = (frame.velocity - v) * (1 - bullet.factor) + v;
} else if(inertial_velocitys.TryGetValue(bullet.id, out var v)) {
    inertial_velocitys[bullet.id] = v * (1 - bullet.factor);
}

&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;除了上述的好处以外，这么做还能带来另一个好处是在竖直运动的参考系中（比如电梯），可以很好的表现失重（重力加速度突然变小）和超重（重力加速度突然变大）的情况：参考系的加速减速运动不影响空中的子弹（手雷）。&lt;/p&gt;

&lt;h2 id=&quot;仍然没有解决的问题&quot;&gt;仍然没有解决的问题&lt;/h2&gt;

&lt;ol&gt;
  &lt;li&gt;参考系在变加速过程中不规则的改变Collider位置，会导致一些Collider在空间位置上相交，从而使物理检测失效。(手雷在变加速剧烈的电梯里会漏到电梯井里)&lt;/li&gt;
  &lt;li&gt;主角还是使用物理和刚体来实现移动和惯性参考系交互的，这与其他单位同步的方式不同而产生表现差别。&lt;/li&gt;
  &lt;li&gt;World模式的粒子特效没有好的办法或表现随参考系移动。&lt;/li&gt;
  &lt;li&gt;主角在被火车电梯等参考系撞开时，同步单位无法表现。&lt;/li&gt;
  &lt;li&gt;没有实现可配置的脱离参考系的时机（起跳离开参考系和落地以后才切换参考系）。&lt;/li&gt;
&lt;/ol&gt;

&lt;h2 id=&quot;结&quot;&gt;结&lt;/h2&gt;

&lt;p&gt;开发一个运动的惯性参考系，并在其中实现完全的战斗体验是一个庞大的系统工程，实际工作中除了上面列举的问题以外还有毛毛多的细节、特殊处理分支和各种各样的Bug，要想表现完整还是道阻且长T_T。&lt;/p&gt;
</description>
        <pubDate>Wed, 03 Feb 2021 00:00:00 +0000</pubDate>
        <link>http://www.lpq.design/2021/02/inertial_system/</link>
        <guid isPermaLink="true">http://www.lpq.design/2021/02/inertial_system/</guid>
        
        <category>GamePlay</category>
        
        <category>Unity</category>
        
        <category>Physics</category>
        
        <category>InertialSystem</category>
        
        
      </item>
    
  </channel>
</rss>
