动画示例项目旋转浅析 - GASP Rotation

动画示例项目旋转浅析 - GASP Rotation

【USparkle专栏】如果你深怀绝技,爱“搞点研究”,乐于分享也博采众长,我们期待你的加入,让智慧的火花碰撞交织,让知识的传递生生不息!


前言

UE:5.7.1

GASP:5.7

本文是以Mover版本角色来进行浅析GASP中旋转的实现,包括Strafe移动,AimOffset,TurnInPlace。

同时也会结合分析Lyra和ALS的一些实现方案。

1. Debug与基础RotationMode

1.1 Debug

为了更方便查看旋转相关的Debug信息,我们需要打开Widget中的Draw Shapes,就会看到下图这样很多的箭头,其中有意义的部分为:

  • 圈内黑色大箭头:Root根骨骼朝向
  • 圈外黑色小箭头:角色(碰撞体)朝向
  • 亮粉色箭头:TargetOrientation旋转偏移前的目标朝向
  • 暗粉色箭头:TargetOrientation+RotationOffset旋转偏移后的目标朝向
  • 橙色箭头:MovementIntent,角色的期望运动方向
  • 绿色弧线:当前MovementDirection移动朝向的范围

1.2 RotationMode
在GASP中,旋转模式分为OrientToMovement、Strafe和Aim,如果你是用过ALS的,那么它们是与VelocityDirection、LookingDirection和Aiming一一对应的。

  • OrientToMovement:角色将旋转至面向移动方向
  • Strafe:角色在移动时将大体面向瞄准方向旋转
  • Aim:角色将始终以最小偏移量精确面向控制器旋转方向,也算是Strafe模式

RotationMode主要是由玩家的按键输入以及游戏模式决定,在GASP中可以通过鼠标中间切换Strafe和Movement两种模式,并且可以按住右键进入Aiming模式。在常见的开放世界游戏中大多都是默认使用OrientToMovement模式,而当玩家拿起弓箭或者枪械瞄准时则会进入Strafe之中。

其中OrientToMovement比较简单,角色朝哪边移动就转向哪边即可,就不过多介绍。

2. Strafe基础

2.1 BlendSpace做法存在的问题
记得我最早接触Strafe移动还是在跟着一些UE4新手教程做战斗系统的时候,当做到了弓箭瞄准,或者类似只狼锁定目标时,就需要出现玩家始终面朝某个方向,却向着其他方向移动的情况。但是新手教程中的做法往往很简单,例如下图这种,用一个Direction来表示离面朝方向偏差多少角度,然后一个1D BlendTree混合。也有稍微好一点点的会使用2D BlendTree,使用LR/FB来进行混合。

1D因为不是环形的,在遇到从左后切换到右后时,需要经过-180度到180度把中间状态全都走一遍的情况,表现十分奇怪。而2D无论是四向在融合的地方都可能出现预料之外的表现。对于一些融合表现不好的地方可以选择补全八向动画。

若使用RootMotion动画,混合后会出现移速衰减问题,例如前后、左右单方向移动速度均为100,在往左前走的时候速度就只有50 √2 ,但是我们的期望肯定是斜向移动也是100,最简单的做法通过调整动画播放速度,来调整移动速度,但是会比较鬼畜。也可以考虑用程序补偿,缩放一下,或者直接用程序位移。

2.2 程序化多方向动画
二维的BlendTree也并不是要固定四个方向的动画,可以根据需要灵活调整为8向,10向都是OK的,GASP则是用到了四个动画F\B\LL\RL,但GASP并不是通过BlendTree混合出来的,使用BlendTree融合在没有动画的角度融合的角色往往会不尽人意,其使用的是OrientationWarping,同样是程序化修改在小范围内效果还是可以的。Lyra更是暴力地只用一个动画就OrientationWarping了360°出来。

OrientationWarping能使脚根据运动方向向不同方向移动。让下半身运动匹配移动方向,而上半身保持不变,以此实现向不同角度进行Strafe。他的实现逻辑是先旋转Root,再反向依次旋转Spine,例如下图中就是使用向正右方移动,但是被Warp了45度到了右前方。

2.3 腿部穿插
无论是BlendSpace还是OrientationWarping这些方法都无法解决脚步交叉的问题。而脚步打绕常出现于交叉脚的时候去往另一个Pose融合,但是切换过去的脚前后相反,直接融合过去就会穿插,比如下图本来是向右走的左脚在前,交叉时切回向后的右脚在前,腿就穿了。

为了解决这个问题,一个比较常见的做法就是分扇区,将前向和后向分别作为不同的BlendTree,在每个扇区中都让同一只脚保持在前方,同时保证同样播放进度时脚都是同一的前后位置,这样在扇区内Blend的时候就不会出现前腿要放到后方的穿插情况。同时为了避免在边缘时扇区频繁切换还会加入DeadZone,则让每个扇区的范围扩大一些,有一些重叠区域。

当融合发生时,融合前后的Pose一只脚在前,另一个Pose脚在后就会出现融合时穿插的问题,但其实要修复这个也非常简单,只需要在合适的时机进行融合也可以解决,要么相同脚在前,要么处于非交叉状态,正如下面视频展示。

我们可以选取当他到了什么时间节点我才允许切换过渡,但是这样会影响手感,更好的做法是使用SyncGroup让切换过去的时机对应。

除此之外还可以通过衔接动画方式来解决,通过过渡动画替代动画直接融合,来规避脚部穿插的情况,这样还可以改善在切换过程中仍然是两个直接动画融合可能让表现不符合预期的问题。

3. GASP的Strafe

3.1 MovementDirection分扇区
在GASP中,为了避免脚步打绕穿插的问题,同样也用到了分扇区的做法。

在最新的工程中他们还开放了一个实验性功能,可以针对于Aim和Strafe分别选择不同的扇区风格,可以是分前后左右四个扇区,也可以只有两个或者一个。

但是如果你打开枚举类型会发现,并不是只有F\B\L\R这四个,而是F\B\LL\LR\RR\RL这六个。如果你看过ALS你就会感觉非常的熟悉,和ALS的RF\RB类似,RF代表角色向右运动但是胸口朝右,RB表示角色向右移动但是胸口朝左(背部朝右)。而GASP中也是一样,第一个字母L/R表示向哪边移动,第二个L/R表示胸口的朝向,例如RL就表示朝右移动,背部朝左(胸口朝右)如下图所示:

对于MovementDirection的选取也很简单,根据当前运动朝向处于哪个范围内就选择哪个,但是并不是左右胸口朝向不同的都上了,在当前Mover版本的实现中,采用了LL作为L,RL作为R,组成了F\B\L\R四个部分。(不过看动画也预留了RR和LR,后续可能会扩展成ALS那样六向。)

通过FL\FR\BL\BR四个角度值,就可以区分出四个扇区,如下图所示,同时在每个扇区时都会稍微扩大当前扇区范围以此来形成一个DeadZone,避免在边缘疯狂跳转。

在Debug模式中的绿圈就是表示当前扇区的范围,根据我们选择的Strafe Style不同,扇区覆盖的区域就会有所变化,就实现了控制扇区数量。例如在F\B模式中,FL和BL是相同的值,所以就不存在进入L扇区的情况。

3.2 Rotation offset

在操作Mover角色移动时,如果你打开Debug会经常发现有一个暗紫色的箭头,并且角色的朝向会跟随他。让我们偏移我们实际想要的朝向(亮紫色箭头)。这就是RotationOffset,和ALS的Yaw Offset是一个东西。

这东西的计算很简单,就是根据现在的移动方向和期望朝向的夹角来采样,比如上图的情况,亮紫色的期望朝向TargetOrientation和橙色的运动方向夹角为+135°,并且此时处于B的MovementDirection ,就会采样Curve_RotationOffset_B得到-45的RotationOffset.

关闭RotationOffset后,在同一扇区内动画表现差异不大,但在切换扇区时可能出现衔接问题,因为角色的朝向一直向前会导致选择到错误的衔接动画,这个后面会说。

(这里还有个小细节是GASP的OrientationWarp有个AngleThreshold开的135,为了测试这个我们需要扩大一些,这个值本质是为了防止Orientation过渡,不然在超过135度时会反转成向背面Warp45度造成错误。)

RotationOffset的作用主要还是让角色的动画更加自然,下图中一个是带Offset一个不带的,虽然没有截到同一帧动画,但是可以看出动画还是略有差异的。在有RotationOffset的时候,角色移动方向和朝向差距180°,正好和原动画一样,就不需要OrientationOffset来程序化的修改动画,毕竟动画师K好的动画大概率是比融合或者程序化修改后的表现好一些。而在ALS中YawOffset就是为了避免多方向的融合,尽量使用原动画。两者都是为了尽量使用更加自然的原动画。

但是如果是这样的话为什么不一直保持这个RotationOffset呢?这样就不需要BlendTree的融合或者程序化的修改了,能够一直使用原始动画。在下面图片中可以看到GASP在右侧时,移动方向为右下时有45°的偏移,但是朝向右上的时候偏移却为0而非我们预期的-45°。左侧也同理。

如果强行改一下让他的左右扇区都有RotationOffset的偏移,可以看到下面视频的效果,因为有AimOffset的存在,虽然脚步正常,但是此时胸口和头是朝向正前方,然后此时角色朝向又朝斜向移动,表现就略显奇怪。同时因为此时是处于小角度45°,OrientationWarp也不会太过扭曲,所以这个时候GASP就选择使用OrientationWarp来处理这个情况而非Offset。

3.3 衔接动画资源
对于扇区之间的切换,GASP采用了过渡动画的解法,依靠于MotionMatching和Chooser制作了大量的衔接动画。

在GASP中有着大量的这种衔接动画,以Run为例就有密密麻麻的上图这么多,做的非常细节,不仅区分了左右脚还有各种不同的类型。这些Box Spin等貌似是舞蹈的步伐专业术语,但是我们可以从轨迹和作用来区分。

  • Spin:在运动方向不变情况下,将角色朝向进行改变
  • Box:在保持朝向不变情况下,运动方向发生改变90度,轨迹为L
  • Pivot:在保持朝向步不变情况下,运动方向发生改变180度
  • Turn:朝向和运动同时进行改变,并且移动方向和角色朝向始终一致
  • Switch:运动方向与朝向都不变,但是胸部朝向左右切换

下图是每个类型举了一个例子:

3.4 衔接动画选取
这么多资源,还都有着不同的功能,那么在GASP中是如何选取使用的呢?

在GASP中是通过Chooser选取一系列合适的动画,再进行MotionMatch选择。

其中选择的条件中需要StateMachineState为Transition to Locomotion才会被选中,而这个状态的进入条件主要就两类:一种是当前状态发生变化,比如最关键的就是movementdirection;另一种就是正在播,但是又快速有了调整导致当前动画不符合,就会再次进入再次触发选择合适的动画。

但StateMachineState并不能作为我们区分这么多动画的依据,主要还是靠的后面三个:FutureFacingDelta、IsPivoting和MovementDirectionRecent。其中FutureFacingDelta、IsPivoting都是根据未来轨迹预测的角度来计算。

FutureFacingDelta是由当前Root朝向和期望朝向的差值计算得到,用来计算角色的朝向在未来发生变化的角度,也就是Debug模式下的暗粉色箭头的变化。通过这个参数就可以区分角色朝向发生变化的Spin,Turn与未变化的Switch,Box,Pivot。同时根据这个参数还可以区分Turn的角度,转向的左右,以及使用哪个Spin动画,例如我在LL情况转回F则是向左转90度,而表中这个动画的值范围是-110到10度,正好满足同时多了+-20的容差。

IsPivoting用来判断运动方向有没有发生变化,它是通过未来轨迹和当前运动方向计算一个方向夹角,如果大于Ground是75度并有一定的移动速度就为True,使用这个就可以将Spin、Switch这类运动方向不变的动画与其他区分开。

在最开始我们介绍过MovementDirection,但它和这个带Recent的参数还不太一样,当我们比如切换到F方向时,是先使用MovementDirection选择当前方向,然后再在这里面选择从哪个方向转过来,除了刚刚的FutureFacingDelta描述转多少度之外,还有MovementDirectionRecent用来表示上一个稳定的状态是什么,这个参数是只有在当前MovementDirection保持0.1s以上才会储存更新。

这样在一些快速切换的时候,角色朝向和运动朝向其实都没有发生变化,Recent这个参数就会让没有动画被选择从而跳过衔接动画直接回到本来的Loop,避免播放了衔接动画但实际上并不需要从那衔接过来的情况。同时也可以为Switch动画提供选择依据,因为方向都没变化只有通过Recent来选择。

所以总结下来,对于之前提到的各个动画类型的示意图来说,FutureFacingDelta决定角色朝向变化,也就是紫色箭头的信息。IsPivoting决定运动方向是否发生变化,也就是橙色箭头的信息。MovementDirectionRecent决定当两者都不变化时,如何选择,也就是区分胸口朝向,绿色箭头信息。三个参数共同作用就解决了动画DataBase选择的问题。

最后再根据MotionMatching来匹配L/R Foot,以及以未来轨迹决定运动轨迹来完成最后从动画库中的选择。

4. AimOffset

AimOffset还是比较常规的做法,用了一大堆的Pose组成一个BlendSpaces,用Yaw和Pitch来采样。最后通过MeshSpaceAdditive叠加上去,至于为什么是MeshSpace可以看官方文档的解释:
https://dev.epicgames.com/documentation/unreal-engine/aim-offset-in-unreal-engine

但是AO有个细节还是可以关注下,GASP的AO也有平滑,但是它并不是对最后计算完毕的Yaw和Pitch进行平滑,因为这样会导致如下面视频一样(红色箭头代表Head朝向)的情况,因为AO是Root和Aiming方向的插值,Root转动比较快而AO再慢慢平滑的话就会出现偏离目标,头在转身过程中多歪了一些再回正。

解决也很简单,就是对Aiming方向进行平滑,但是计算Root和Aiming的差值不平滑,这样就能够跟上Root避免平滑导致的错误偏移。

5. 原地转身

5.1 ALS与Lyra原地转身回顾
在没有Turn的时候,角色旋转要么一直跟随相机像下面视频一样直接整体旋转却非常不符合正常生物的表现。而如果一直不旋转,只靠着AO去看向目标,在当我们的目标点在身后时,人是没办法把头旋转180°看到后方的,而且一般AO极限也就90°左右了。想一下现实中当我们想要看向另一个方向到达一定角度时,我们就会动一动脚转一下身子,这就是TurnInPlace。

在看GASP怎么做的之前,我们可以回归一下ALS与Lyra的流程。

ALS的角色旋转朝向没有使用CMC自带的ControllerDesiredRotation或者RotationToMovement。所以在移动时候能够根据期望朝向去旋转,而原地时如果没有特殊逻辑则会保持Actor不旋转。

当ALS处于Strafe或者Aiming模式下时,会计算Control和Actor的朝向夹角,当超过一定角度后(Strafe还会要求并且保持一定时间)就会根据方向和角度选择合适的动画播放。每一帧的旋转量会被存在动画曲线中,根据实际旋转角度调整动画播放速度和缩放旋转曲线值以实现一个动画对应多个不同的角度,并每一帧应用这个旋转曲线值就实现了原地旋转。

但是这样会存在一个问题,就是如果游戏帧率很低,那就会跳过一些曲线点导致旋转被应用的不够,ALS是直接强行差了几倍时间就缩放几倍,这样会导致最后有所偏差。

Lyra则比较简单,首先因为Lyra的Actor旋转是始终跟随ControlRotation的,所以Mesh也会跟着动,而为了让Mesh保持不动,Lyra在ABP中会计算一个差值然后反向应用给Root让其保持不动。

在旋转动画Lyra并未考虑转身动画适配不同角度,而且是暴力的直接播放转90度动画,在转身后使用AO去对齐目标。

5.2 DistanceMatching For Turn In Place
(修正:这里提到的AdvanceTime和UE的节点不太一样,UE节点是输入Delta,然后根据Dleta进行前进,并不会追逐剩余时间,而我这里直接把AdvanceTime当做了输入Remain,更像是ToTarget带RateLimit。)

对于这个方案是UE官方在Paragon Feature Examples和Bringing a Hero from Paragon to Life with UE4的分享中提到(链接在末尾)。虽然DistanceMatching在熟知的Lyra中被用在停步的距离匹配。这个不只可以匹配移动距离也可以用来做旋转,当使用DistanceMatching做旋转时,我们可以根据想要旋转的角度,以及旋转曲线来匹配合适的时间点。而非基于时间的线性播放来驱动。

Lyra在起步停步动画的时候,用到了两个叫DistanceMatching的节点,

虽然DistanceMatching本质是调整动画播放的时间点,如果使用DistanceMatchToTarget则会直接跳到合适的位置播放,比如停步的时候就可能会是前面的动画不播放而从后面直接开始,另一个AdvanceTime如果去掉PlayRateClamp,其实和ToTarget是一样的,但是因为有PlayRateClamp的存在,也可以理解为是缩放了动画播放速度。但是相比于简单的缩放PlayRate,可能实际运动的节奏和动画的节奏不一定能对上,可以看下图。如果只是缩放播放速度,虽然最终时间对上了,但是过程中也和实际位移对不上就会滑步,就如下图所示。当动画位移和实际位移是一致时候,缩放PlayRate和使用DistanceMatching。在动画位移过大就会减慢速度等一等,当动画过慢就会加速播放追上去。

但是实际在使用过程中,并不会出现实际位移和动画位移相同的情况,在下图中左图是动画位移大于实际位移,右图是动画位移小于实际位移。因为有PlayRateClamp的限制,所以会导致最终动画位移和实际位移有差距从而滑步。

在动画位移较小,使用ToTarget会丢失前面动画,而AdvanceTime会先播放较慢速度,让实际剩余位移追上动画剩余位移。

在动画位移大的时候,使用ToTarget会丢失前面动画,AdvanceTime则会先加速动画让动画的剩余位移减少,追上实际剩余位移。

虽然上面都是在说距离,但是把距离换成旋转角度来理解也是同样的。可以看到AdvanceTime的轨迹总有一段是与实际的位移无法对上,会产生滑步问题。而ToTarget虽然可以比较好的匹配,但是代价是会丢失部分动画。

所以如果用到了Turn上时,就和起步动画类似,我们希望他从头开始播放,并且能够对应角度的旋转,我们会使用AdvanceTime。但是由于避免动画太过鬼畜有PlayRate的限制,还是会在某些时刻与实际旋转产生偏差从而滑步,这就需要配合FootLock来解决。所以DistanceMatching只是解决了如果在PlayRate的范围内,转身结束的时候动画也刚好结束。让动画来匹配逻辑旋转。

5.3 TurnInPlace In GASP
说了ALS、Lyra还有Paragon的方案,回来GASP来看。

首先GASP只有在Aiming状态下才会有TurnInPlace的表现。在Mover组件下,角色的朝向是由输入的OrientationIntent决定。在Aiming原地时,是超过60°时才更新OrientationIntent为AimingRotation的方向。之后通过一个FacingSmoothingTime的参数平滑过去。如果把这个角度限制去掉,那么就和CMC的UseControllerDesiredRotation一样,角色旋转会一直平滑转向控制器方向。

之后通过Root和期望朝向的差值计算得到前面说过的FutureFacingDelta,当这个值超过50°时就会触发shouldTurnInPlace,切换DataBase为原地Turn动画触发Turn。

这里在Aiming模式下60°才会修改角色期望朝向,而一旦改变为60°>50°就会触发TurnInPlace,但是如果我们把触发TurnInPlace的角度扩大呢?让它变为90°就可以看到下面视频中的情况(关闭了OffsetRoot)。还没有触发TurnInPlace但是角色转过去了。除非像最后甩鼠标一下转超过90°不然就永远触发不了Turn,直接滑过去。

但是这并不正确,在没有到达触发Turn角度的时候,我们正确应该是保持在原地不旋转,但是因为我们胶囊体已经转了,想要Mesh不转我们就需要将Root给反算回去,这就是Lyra的做法。而GASP采用了一个新节点来实现,避免了还需要在蓝图各种计算最后再使用ModifierBone节点修改Root。

在OffsetRootBone节点上,RotationMode是除了播放Montage时使用Release模式,其他情况都是Accumulate。在这个模式下,节点会保持Root节点在误差范围内保持不变,不会因为胶囊体作为父级旋转而让Mesh的Root也跟着旋转,在GASP中MaxRotationError值为-1表示无限大。当我们在刚刚那个视频同样条件下开启OffsetRootBone,再旋转60°时候,可以看到胶囊体已经旋转过去,但是我们的Root仍然保持不变。

解决了Root的问题,我们就可以接着看转身动画的逻辑,在shouldTurnInPlace触发后,状态会跳转进入Transition to Idle状态,触发Chooser根据FutureFacingDelta选择合适动画,这里要注意的是UseMM=False,所以会直接正常播放而不会对这个动画进行MotionMatching选择合适的位置。

首先在下图看到,如果直接播放这个动画,我们的角色大概会转80多度的样子,没有符合动画的90°,这是因为在Idle向Turn动画切换的时候有0.4s的融合时间,在融合阶段Turn动画的RootRotation并未全部应用,所以会有一些偏差,关掉之后就是正常的90°。(在ALS之中RotationAmount曲线也同样有这个问题。)

这个时候问题就来了,我们只是一个90°的动画,怎么让他在有融合干扰的情况下还能刚好转到60°的位置或者更多其他不同的角度呢?同时上面我们还说过有OffsetRoot的Accumulate模式,那我在移动时怎么才能正确的转向呢?

这些问题GASP都使用了Steering节点来解决,这个节点的作用是缩放调整动画的旋转,让Root转向TargetOrientation方向。

因为需要缩放动画,就需要知道未来一段时间动画的位移,所以会有AnimAsset并且会传入AnimatedTargetTime表示向后预测多长时间,如果预测时间不够长则会导致动画缩放不均匀,破坏动画本身的旋转节奏。

因为在Steering生效期间玩家还是可以操作,上一秒希望转30°下一秒可能是135°。所以Steer会动态去追随。

Steering内部的做法和ALS的原理很类似,通过预测实际需要旋转角度和动画的旋转角度进行缩放,比如动画是90°但是转60°,则把每次的旋转量只应用66.7%。但同时他也有DistanceMatching的思想,但并不是用在修改时间,而且修改Root。

例如我需要转60°,有一个动画3s转90°,前两秒他会转20°,最后一秒转70°,动画节奏为先慢后快,但本文预测时长仅设置2s。

所以在开始的时候,预测到了动画有20°,实际有60°。理论应该缩放3倍,但是因为他也有Max/Min ScaleRate的配置,会导致只缩放1.5倍。虽然前2s转了30°,后1s因为有缩放限制最大缩放到0.5倍,转了35°。最终导致实际旋转为65°而非期望的60°,和DistanceMatching的AdvanceTime非常类似。

可能是因为预测时间不足,也可能是因为旋转过程中Target产生了变化导致Root在缩放范围内无法满足,所以除了缩放Root旋转之外,Steering还有校正补偿的部分。同时像RunFLoop这种没有根旋转动画也能正常转向也是因为校正补偿在起作用。

他的实现就是通过弹簧平滑补偿,而ProceduralTargetTime就决定了平滑的误差半衰期,越小就会平滑的越快。

在GASP中,TargetOrientation并不是使用当前的期望朝向,而是根据轨迹预测的。这使得转向节点能够朝向未来的方向旋转,而不总是朝着当前角色的旋转方向旋转,否则可能会导致他落后得太久。

理论上这一个节点应该可以解决原地转身和动画朝向的问题,但是GASP目前做了两个Steering节点,在原地转身的时候第二个也会生效。他们的主要差别在于RootMotionAngleThreshold从-1改为1和Disable Steering Below Speed从10改为-1。

第二个Steering的Disable Steering Below Speed可以避免在原地不动时Steering不生效,RootMotionAngleThreshold是当动画的RootMotion小于这个角度就不会执行RootScale的过程,但看起来对于第二个Steering幅度下应该没什么作用。

5.4 FootLock
无论是上面的哪种方法,在调整旋转量后都会出现滑步的问题。对此的解决方法就是FootLock。在脚步放在地上时将其锁住,让他不会因为角色整体旋转对不上而滑动。

在GASP中叫做FootPlacement,其做法也很常规,就是通过给动画打曲线标记记录哪个时刻脚步是落下的,之后在对应时刻将脚锁住即可。

他的具体实现是放在ControlRig中,在那里面GASP还做了很多小细节来修正一些表现,到这里篇幅已经有点大了,后面的文章再来探究ControlRig中的一些东西。

6. 后记

本文分析的都只是GASP中旋转的主要思路,同时也对比了GASP之外的其他实现方案,如ALS、Lyra等。

但是其中还有大量细节处理逻辑,还有一些比如处于连续转身的情况下避免选择Pivot动画,以及许许多多没有提及到的。

参考
Unreal AimOffset 官方文档

Paragon Feature Examples官方分享

Bringing a Hero from Paragon to Life with UE4


这是侑虎科技第2016篇文章,感谢作者Shadow供稿。欢迎转发分享,未经作者授权请勿转载。如果您有任何独到的见解或者发现也欢迎联系我们,一起探讨。(QQ群:793972859)

作者主页:https://www.zhihu.com/people/shadow-21-71-4

再次感谢Shadow的分享,如果您有任何独到的见解或者发现也欢迎联系我们,一起探讨。(QQ群:793972859)