SomeNotes


  • 首页

  • 标签

  • 分类

  • 归档

联机GamePlay

发表于 2022-07-09 | 分类于 network

联机GamePlay

技能

技能细分的话由可以分为主动技能,被动技能,各种游戏中也常将这两个概念分开来理解.不过从Gameplay的开发来说,被动其实可以理解为需要条件触发的主动技能.

无论是主动技能还是被动技能,通常都是由多个游戏逻辑组合而成,譬如一个技能会让角色播放动画,对命中的敌人造成伤害,给自己添加Buff…这些逻辑在游戏世界中通常需要一个逻辑承载体,在项目中这个承载体是AGE Action, Unreal的Gameplay Ability System框架下这个承载体是Gameplay Ability。程序实现Gameplay的原子(接口)提供给承载体,策划使用这些接口来实现最终的主动技能效果。

如果是单机模式,释放一个技能只需要将这个逻辑承载体创建在场景中并执行。

联机模式下,技能就需要根据情况选择不同的处理模式:

本地预表现

a

技能的预表现与移动的预表现逻辑类似,先在本地客户端执行技能逻辑,再把释放技能的指令发送到权威服务器进行校验(对应Unreal Gameplay Ability System的GameplayAbility::NetExecutionPolicy::LocalPredicted).

  • 如果校验成功可以释放技能,那么就在服务器上执行技能逻辑,并用RPC通知其他客户端该角色释放了技能,在其他的客户端上也执行技能逻辑。

  • 如果校验失败,那么权威服务器和其他客户端都不会执行技能逻辑,权威服务器还会通知本地客户端校验失败,回滚本地执行的逻辑。

因为有预表现,玩家输入指令后本地客户端的角色能够立刻做出反应,手感是较为优秀的,但是问题在于可能会有客户端和服务器的逻辑不一致。

图中对时间轴进行了拉伸

设想这样一种情况,某个时刻玩家释放主动技能,这个主动技能的效果是立刻发射出一颗带轨迹的子弹,但在这个时刻,服务器认为该角色已经被其他玩家冰冻了,不应该释放技能。如果不做任何处理,权威服务器会命令本地客户端回滚技能效果。

从主动技能释放,到最终被服务器的RPC纠正回滚,至少会有一个RTT的延时,如果在这段时间内这个子弹命中了敌人,就会有本地客户端观察到子弹命中了,但却没有照成伤害。

服务器启动

服务器启动则是释放技能后,客户端并不立刻执行技能逻辑,而是发送RPC请求到服务器,等待服务器校验通过并通知客户端可以释放,本地客户端才执行技能逻辑(对应Unreal Gameplay Ability System的GameplayAbility::NetExecutionPolicy::ServerInitiated).

这样做的优势在于,客户端等待服务器的确认才能释放技能,技能逻辑不会出现与本地预表现那样逻辑不一致的情况.但是缺点也很明显,玩家从输入释放技能到最终看到技能的表现,中间至少有一个RTT的延时,手感通常较差.

根据个人的经验,蓄力释放的技能使用服务器启动的方式是较为合适的,客户端RPC通知服务器开始蓄力,服务器RPC通知客户端释放技能的延时感受都会被融合在蓄力中.

被动触发的技能通常也是使用服务器启动的方式来实现,服务器上触发被动技能后再通知客户端触发技能.

更复杂的模式

实际的开发中仅使用上述模式并不能处理所有的Gameplay,Bungie在GDC Halo中的联机Gameplay中提到了一个无敌护盾的例子:

这个技能的效果是在短暂的准备动画后,角色身上添加护盾特效并进入无敌状态.

简单的想法是使用本地预表现来实现:

  • 本地客户端按下按钮,通知服务器释放技能,并播放前摇动画,随后添加护盾特效.
  • 服务器接收到释放技能的消息后,播放动画,并让角色进入无敌状态.

不过这样实现是有问题的,如果在服务器播放动画期间受到了伤害,很有可能会导致客户端本地已经播放了护盾特效表明进入无敌状态,但还是能受到敌方的伤害.

那么为了逻辑保持一致,是否应该使用服务器启动的模式? Bungie指出这也不是一个最优解,在播放完准备动画后,玩家至少需要等待1个RTT的延迟才能看到自己身上添加了护盾特效:

最终Bungie的解决方式是该技能本地客户端执行动画前摇,服务器则缩短前摇时长到1个RTT:

  • 如果在服务器前摇期间受到了伤害,伤害发送到客户端时角色正在播放准备动画,受伤可以理解.
  • 如果在服务器进入无敌后受到攻击,无论客户端正在播放准备动画还是激活了护盾特效,角色都不会受到伤害,技能效果表现与逻辑达成一致.

一点理解

总的来说技能的联机实现没有一个通用的解法能够处理所有情况,更多的是根据需要选择不同的方案,或是对通用解法进行优化,掩盖其缺点.

Buff

玩家对角色移动,技能特效这些的延时比较敏感.但对于大部分逻辑都是修正属性的Buff来说却不是那么敏感.因此一个简单却好用的实现方式就是客户端完全听从服务器对Buff的添加和移除:

不过Unreal在GAS中对Gameplay Effect也进行了预表现,下面是前人对GAS联机模式的同步流程梳理:

腿部IK在ALS中的实现

发表于 2022-06-26 | 分类于 UnrealEngine , ALS

从UE的插件Advanced Locomotion System分析一下腿部IK的实现.

Virtual Bone

在骨骼树上可以新增Virtual Bone跟随骨骼树上的指定骨骼变换位置旋转缩放:

ALS的骨骼树在左右脚的骨骼末端添加了ik_foot_l_Offset,ik_foot_r_Offset分别跟随SkeletonTree的Foot_L,Foot_R运动,设置腿部IK时会检测腿部与地面的碰撞修正这两个虚拟骨骼的位置.

计算偏移

进行脚部IK需要进行检测脚底是否有可以接触的地面,如果人物站立的地方是斜面,需要根据斜面的角度调整一些骨骼的位置,这个逻辑在ALS的动画蓝图的SetFootOffsets函数中,核心在于计算骨骼需要移动的向量:

如图所示,角色站在斜面上,以左脚的检测为例,从左脚所在的位置往上一段距离作为检测起点,往下一段距离作为检测终点,进行射线检测:

如果检测到了地面,那么需要计算Foot_L实际需要在的位置和偏移向量(图中红色虚线):

1
2
Foot_L_ShouldBePosition = TraceHit+HitNormal*FootHeight;
FootOffset_L_Target = Foot_L_ShouldBePosition-Foot_L_Position;

还需要根据碰撞点的法线计算骨骼旋转:

1
targetRotator = MakeRoatator(arctan(hitNormal.y/hitNormal.z),-arctan(hitNormal.x/hitNormal.z),0);

计算完毕后并不会直接使用这两个目标偏移和旋转,而是插值到这个目标值,插值偏移时还会根据当前数值和目标数值的z值大小不同进行不同速度的插值:

除了脚部的骨骼需要偏移之外,整个骨架其实也是要往下移动的.ALS中的实现方式是移动SkeletonTree的Pelvis节点,这个移动向量取FootOffset_L_Target,FootOffset_R_Target中z值更小的向量(站在斜坡上,重心往下移).

1
PelvisTarget = FootOffset_L_Target.z<FootOffset_R_Target.z? FootOffset_L_Target:FootOffset_R_Target;

当然这个PelvisTarget也是需要渐变插值来使用:

TwoBoneIK

在EventGraph中计算好了需要的数值后,接下来就是在AnimGraph中使用这些数值来进行骨骼变换和IK了.这个逻辑在BaseLayer的FootIK中.

首先是把左右脚的Virtual Bone(ik_foot_l_Offset,ik_foot_r_Offset)偏移到正确的位置,再向下移动Pelvis骨骼:

然后是向外偏移跟随calf骨骼运动的虚拟骨骼(ik_knee_target_l,ik_knee_target_r),提供极向量:

极向量的作用是为两球求交的TwoBoneIK问题提供一个唯一解,可以参考Games104中IK与动画的课程:

最后就是使用TwoBoneIK节点进行实际的IK求解了,IKBone是需要求解的目标骨骼,这里是(foot_l,foot_r),Effector提供骨骼目标位置(ik_foot_l_Offset,ik_foot_r_Offset),JointTarget则提供目标极向量(ik_knee_target_l,ik_knee_target_r).:

最终效果:

UECharacterMovement

发表于 2022-06-21 | 分类于 UnrealEngine

UECharacterMovement

SimulateSmoothing

Simulate每次接收到Server的位置信息都需要平滑插值更新,UCharacterMovementComponent::SmoothCorrection在ENetworkSmoothingMode::Linear和ENetworkSmoothingMode::Exponential模式下直接设置Collider的位置和旋转并保存接收的新位置与旧位置的差量:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
UCharacterMovementComponent::SmoothCorrection()
{
//...
FVector NewToOldVector = (OldLocation - NewLocation);
//...
ClientData->MeshTranslationOffset = ClientData->MeshTranslationOffset + NewToOldVector;
if (NetworkSmoothingMode == ENetworkSmoothingMode::Linear)
{
//...
const FScopedPreventAttachedComponentMove PreventMeshMove(CharacterOwner->GetMesh());
UpdatedComponent->SetWorldLocation(NewLocation, false, nullptr, GetTeleportType());
}
else
{
//...
const FScopedPreventAttachedComponentMove PreventMeshMove(CharacterOwner->GetMesh());
UpdatedComponent->SetWorldLocationAndRotation(NewLocation, NewRotation, false, nullptr, GetTeleportType());
}
}

FScopedPreventAttachedComponentMove作用是作用域内将Mesh与Character的相对关系分离,Characer设置位置时不影响Mesh的位置.并在析构时还原相对关系,也就是说Characer的碰撞体已经在新位置了,Mesh还留在原地.

UCharacterMovementComponent::SmoothClientPosition则会用ClientData->MeshTranslationOffset插值计算Mesh位置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
void UCharacterMovementComponent::SmoothClientPosition(float DeltaSeconds)
{
//...
//衰减OriginalMeshTranslationOffset
SmoothClientPosition_Interpolate(DeltaSeconds);
//根据OriginalMeshTranslationOffset调整Mesh位置
SmoothClientPosition_UpdateVisuals();
}

void UCharacterMovementComponent::SmoothClientPosition_Interpolate()
{
//...
if (NetworkSmoothingMode == ENetworkSmoothingMode::Linear)
{
//...
//线性衰减到0
ClientData->MeshTranslationOffset = FMath::LerpStable(ClientData->OriginalMeshTranslationOffset, FVector::ZeroVector, LerpPercent);
}
else if (NetworkSmoothingMode == ENetworkSmoothingMode::Exponential)
{
//指数衰减到0
const float SmoothLocationTime = Velocity.IsZero() ? 0.5f*ClientData->SmoothNetUpdateTime : ClientData->SmoothNetUpdateTime;
ClientData->MeshTranslationOffset = (ClientData->MeshTranslationOffset * (1.f - DeltaSeconds / SmoothLocationTime));
}
}

void UCharacterMovementComponent::SmoothClientPosition_UpdateVisuals()
{
//...
if (NetworkSmoothingMode == ENetworkSmoothingMode::Linear)
{
const FVector NewRelLocation = ClientData->MeshRotationOffset.UnrotateVector(ClientData->MeshTranslationOffset) + CharacterOwner->GetBaseTranslationOffset();
Mesh->SetRelativeLocation_Direct(NewRelLocation);
}
else if (NetworkSmoothingMode == ENetworkSmoothingMode::Exponential)
{
const FVector NewRelTranslation = UpdatedComponent->GetComponentToWorld().InverseTransformVectorNoScale(ClientData->MeshTranslationOffset) + CharacterOwner->GetBaseTranslationOffset();
const FQuat NewRelRotation = ClientData->MeshRotationOffset * CharacterOwner->GetBaseRotationOffset();
Mesh->SetRelativeLocationAndRotation(NewRelTranslation, NewRelRotation, false, nullptr, GetTeleportType());
}
}

SmoothClientPosition_Interpolate会根据插值平滑的方式递减MeshTranslationOffset,线性插值是根据时间线性递减,指数插值则是指数递减直到变成FVector.Zero,也就是追上新位置.SmoothClientPosition_UpdateVisuals则是根据MeshTranslationOffset设置Mesh相对Character碰撞体的位置.

总的来说是想实现这样的效果:

PerformMovement

一次在地面行走的堆栈:

PerformMovement->StartNewPhysics->(可能分支)PhysWalking->(可能分支)->[CalcVelocity][SafeMoveUpdatedComponent]->MoveUpdatedComponent->USceneComponent::MoveComponent

AutonomousMovement

发表于 2022-06-14 | 分类于 network

1P玩家移动同步

预表现

使用状态同步实现的联机游戏,对1P的移动预表现是个常见实现,大致的流程为:

  • 接受到玩家的输入后.Client在每次Tick中[执行移动逻辑].
  • Client将移动的结果和玩家输入同时发送给Server,并把本次Move的关键信息(加速度,移动后位置…)保存在列表中.
  • Server收到Client的输入后执行与Client相同的移动逻辑.
  • Server校验运算结果与Client的运算结果,如果Server与Client发送的运算结果一致,发送Ack到客户端确认移动生效.如果不一样,Server就会命令Client回滚.
  • 将Server的运算结果推送给3P客户端。

在这个过程中[执行移动逻辑]可以理解为:

1
2
3
4
5
void PerformMove(float DeltaTime)
{
Velocity += Acceleration*DeltaTime;
Location += Velocity*DeltaTime;
}

也就是简单的根据加速度速度计算位置,这个过程在Client端是每次Tick都会执行:

1
2
3
4
5
6
void ClientTick(float DeltaTime)
{
...
PerformMove(DeltaTime);
...
}

不过Server的Tick在大部分情况下并不会执行移动逻辑,而是在Client的RPC驱动下执行:

1
2
3
4
5
6
7
8
9
10
11
void ServerTick(float DeltaTime)
{
//不做实际移动的计算
}

void OnRPC_MoveFromClient(MoveData move)
{
float DeltaTime = move.DeltaTime;
this->Acceleration = move.Acceleration;
PerformMove(DeltaTime)
}

以一个平面2D游戏玩家操作让角色从(0,0)点移动(1,0),再移动到(2,0)为例:

simplePre

回滚前滚

一般来说Client的预表现结果与Server的校验结果是一致的.但如果不一致(比如被其他玩家眩晕了),Client就需要进行回滚和前滚操作.

回滚容易理解,就是用Server进行PerformMove后的位置信息强制覆盖Client的位置.

那前滚又是怎么回事呢?Client执行移动逻辑后,会将本次Move的信息存储到一个未被Server确认的列表中:

1
2
3
4
5
6
void ClientTick(float DeltaTime)
{
...
PerformMove(DeltaTime);
NotAckList.Add(SaveMove(Acceleration,Location,DeltaTime));
}

如果Server校验通过,Server会发送AckRPC到Client,移除未被Server确认列表中在这之前的项:

1
2
3
4
void OnRPC_AckMove(int ackSN)
{
NotAckList.Remove([&ackSN](move){return move.sn<=ackSN;});
}

如果校验不通过发生了位置修正,那么Client需要把NotAckList中的Move再执行一遍:

1
2
3
4
5
6
7
8
9
10
void OnRPC_AdjustPosition(Vector3 serverPos)
{
Location = serverPos;
for(auto & move :NotAckList)
{
float DeltaTime = move.DeltaTime;
this->Acceleration = move.Acceleration;
PerformMove(DeltaTime)
}
}

这个在逻辑在OverWatch的GDC分享有介绍

UnrealEngine的CharacterMovementComponent中也有对应的实现.

联机下的一些GamePlay

速度修正

GamePlay中常有会影响移动同步的设计,最常见的比如速度修正就能延伸出[加速/减速Buff],[引力源吸引/排斥],[传送带]等玩法.在联机模式下如何如何正确的实现这些GamePlay的同步逻辑?

以加速Buff为例,一个联机组队玩法下,队友能给玩家加速Buff,得到这个Buff的玩家能以120%的移动速度进行移动.

Server作为权威端,是否应该在Buff生效的瞬间将Server上的玩家速度设置为120%?类似于:

1
2
3
4
5
6
7
8
9
10
11
12
class SpeedBuff
{
OnActive()
{
ownerActor.VelocityMuti = 1.2f;
}

OnInActive()
{
ownerActor.VelocityMuti = 1f;
}
}

这样实现其实会导致不同步拉扯,考虑这样一种情况:

notSync1

在白色箭头处触发了Buff并让速度变更生效,在移动包move3发动到Server时,Server的速度变更已经生效,但Client中并未添加此Buff,因此会有Velocity_Server!=Velocity_Client.而速度不一致必然会导致移动后的位置校验失败,导致拉扯.

一个比较合理的实现方式是在Buff触发时Server发送RPC到Client,通知触发速度变更,Client在发送Move时附加速度变更信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
//InServer
class SpeedBuff
{
OnActive()
{
SyncModule.RPC_S2CSpeedChange(1.2f)
}

OnInActive()
{
SyncModule.RPC_S2CSpeedChange(1f)
}
}
//InClient
void C_OnSpeedChange(float velocityMuti)
{
ownerActor.VelocityMuti = velocityMuti;
}
void ClientTick(float DeltaTime)
{
...
PerformMove(DeltaTime);
SaveMove(Acceleration,Location,DeltaTime,ownerActor.VelocityMuti)
}
//InServer
void OnRPC_MoveFromClient(MoveData move)
{
float DeltaTime = move.DeltaTime;
this->Acceleration = move.Acceleration;
this->VeloctityMuli = move.VelocityMuti;
PerformMove(DeltaTime)
}

这样就能保证无论Client什么时候接受到速度变更的通知,Client发送给Server的Move包都能保持一致:

Sync1

而[引力源吸引/排斥],[传送带]这些玩法与速度Buff的区别只在于一个是速度的增量,一个是在原来的速度向量基础上新增了一个新的速度向量,同步逻辑的实现可以完全参考速度Buff.

电梯

联机玩法中一个类似于电梯的组件,多个玩家的角色可以站在上面跟随电梯向上运动,在电梯上玩家也能自由的前后左右移动.

localspace

为了保证每个玩家看到的电梯一致,这个电梯的Owner肯定是Server,因此Server的每次Tick都会驱动电梯运动.

因为网络的不确定性,Client执行预表现移动和Server接受到move数据包时电梯位置有很大的可能不一致,如果不做任何处理Client的move几乎不会校验通过,导致频繁拉扯:

localspaceNotSync

如图中move1,Client认为电梯z=0,执行了移动逻辑并发送校验包,但Server收到数据包时电梯已经在z=10的位置上了,Client的move结果与Server的结果有z=10的差值.

这类问题的同步方案是进入到电梯后,move的校验不再以Client和Server上Actor的世界坐标相等为通过标准,而是两者相对电梯的局部坐标相等即可认为move成功.

如何理解这个局部坐标相等?

  • 假设初始状态Client与Server上电梯都在世界坐标(0,0,0),以电梯为坐标系,电梯最左边为(0,0,0),Actor在局部坐标系(1,0,0).

  • Client Actor进行了移动,此时Client的电梯还为启动,世界坐标是(0,0,0),Actor在电梯上向X正向移动了1个单位,局部坐标位置变为(2,0,0).

  • Client发送move(Acceleration=xxx,DeltaTime=xxx,WorldLocation=(2,0,0),LocalLocation=(2,0,0))到Server.

  • Server在接受到Client的这个move包时已经进行了两次Tick(无论多少次都ok其实),电梯的世界坐标变为(0,0,20),在用这个move包进行移动逻辑后,Actor的位置最终是Actor_Server(WorldLocation=(2,0,20),LocalLocation=(2,0,0)).

  • 可以发现,无论Client与Server上的电梯世界坐标如何,在局部坐标系的Actor局部坐标总是一致的.

同理还可以推及任意轨迹的电梯运动.

UEACTCombo

发表于 2022-06-14 | 分类于 UnrealEngine , ACT

ACT常有攻击动作后根据下一次输入时机和按键进行变招的设计,以游戏鬼泣5中的角色尼禄为例,普攻的时机不同会有不同的连招:

连续两次普攻后暂停一会再接四次普攻(Red QueenCombo C)

连续三次普通后暂停一会再接一次普通(Red QueenCombo D)

这也就是动作游戏和格斗游戏中常说的(“目押”)系统.

目押

目押是指将一系列出招指令按照一定的节奏,间隔输入以达成连段的技巧。是格斗游戏中一类重要的出招,连段技巧。目押被认为是格斗游戏玩家必练的,共通的基本功项目之一。目前市面上存在的各款主流格斗游戏不论采用什么样式风格的按键出招系统,过硬的目押功夫基本都是进阶玩家不可或缺的能力要求。

以攻击动作的动画蒙太奇为例来说明其原理:

  • Timing1 动画开始后禁止输入和移动操作
  • Timing2-Timing3 处理伤害
  • Timing4-Timing5 开放输入操作,允许玩家在此帧域内进行输入,如果有输入则打断当前动画,根据规则进行下一步Combo,否则进入后摇(不可输入)
  • Timing5-Timing6 后摇,禁止输入
  • Timing6 动画结束,开放输入和移动操作

理解了攻击动画片段进行连击原理,那么我们该如何描述多个攻击动画片段组成的连招呢?如果把视频中的两个连招的流程画出来,可以看到这样一张图:

1
2
3
4
5
6
7
8
9
graph TD
立于地面-->|输入攻击|攻击动画1
攻击动画1-->|输入攻击|攻击动画2
攻击动画2-->|输入攻击|攻击动画3
攻击动画2-->|停顿后输入攻击|攻击动画5
攻击动画5-->|输入攻击|攻击动画6
攻击动画6-->|输入攻击|攻击动画7
攻击动画7-->|输入攻击|攻击动画8(Red QueenCombo C完成)
攻击动画3-->|停顿后输入攻击|攻击动画4(Red QueenCombo D完成)

是不是很像数据结构中的二叉树?实际上用树来描述连招是十分合适的.

连击树

一般而言,连击树有如下形式:

1
2
3
4
5
6
graph TD
Idle-->|If Input Attack|FirstCombo
FirstCombo-->|If Meet Condition1|BranchCombo1
FirstCombo-->|If Meet Condition2|BranchCombo2
BranchCombo1-->...
BranchCombo2-->....

树的每个节点则包含了攻击的动画,分支以及分支条件信息,在UE中的定义为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
//基础资源类,继承自UE的DataAsset用于序列化资源文件
UCLASS(Abstract)
class ACTGAME_API UACTDataItemBase : public UPrimaryDataAsset
{
GENERATED_BODY()
public:
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = Item)
FPrimaryAssetType ItemType;

virtual FPrimaryAssetId GetPrimaryAssetId() const override;
};
//连击树实际上只需要一个根节点
UCLASS()
class ACTGAME_API UACTComboTree : public UACTDataItemBase
{
GENERATED_BODY()

public:
UPROPERTY(BlueprintReadOnly, EditDefaultsOnly)
FName ComboName="None";
//连击树的根节点
UPROPERTY(BlueprintReadOnly, EditDefaultsOnly)
FComboNodeStruct Root;
};
//连击树节点
UCLASS()
class ACTGAME_API UACTComboData : public UACTDataItemBase
{
GENERATED_BODY()
public:
//动画参数,用于播放蒙太奇
UPROPERTY(BlueprintReadOnly, EditDefaultsOnly)
TObjectPtr<UAnimMontage> MontageToPlay;
UPROPERTY(BlueprintReadOnly, EditDefaultsOnly)
FName StartSection = FName("None");
UPROPERTY(BlueprintReadOnly, EditDefaultsOnly)
float StartPosition = 0.f;
UPROPERTY(BlueprintReadOnly, EditDefaultsOnly)
float PlayRate=1.f;
//分支信息
UPROPERTY(BlueprintReadOnly, EditDefaultsOnly)
FComboNodeStruct Next;
UPROPERTY(BlueprintReadOnly, EditDefaultsOnly)
FComboNodeStruct Branch;
};
//分支信息结构体
USTRUCT(BlueprintType)
struct FComboNodeStruct
{
GENERATED_USTRUCT_BODY()
//下一个分支
UPROPERTY(BlueprintReadOnly, EditDefaultsOnly)
TObjectPtr<UACTComboData> Combo;
//下一个分支的进入条件
UPROPERTY(BlueprintReadOnly, EditDefaultsOnly)
TSubclassOf<AACTComboCondition> Condition;
};

基于此就可以生成一个资源文件来描述连击树中的一个节点:

FComboNodeStruct中的Condition变量个人觉得是实现得比较有意思的点,最初的想法Condition应该是个返回布尔值的函数,使用的时候就能直接调用决定是否进入下一个分支:

1
2
3
4
5
auto nowCombo=GetCurrentComboNode();
if((nowCombo->Next)->Condition())
PlayCombo(now->Next);
if((nowCombo->Branch)->Condition())
PlayCombo(now->Branch);

但是问题在于并没有找到能将函数序列化的方法.因此只能让Condition继承自Actor并定义接口MeetCondition,调用MeetCondition接口来决定分支逻辑,使用方式变成了:

1
2
3
4
5
6
auto nowCombo=GetCurrentComboNode();
if((nowCombo->Next)->ConditionActor.MeetCondition())
PlayCombo(now->Next);
if((nowCombo->Branch)->ConditionActor.MeetCondition())
PlayCombo(now->Branch);

AACTComboCondition类定义和实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
UCLASS()
class ACTGAME_API AACTComboCondition : public AActor
{
GENERATED_BODY()
public:
AACTComboCondition();
bool MeetCondition(AACTCharacter* _Owner)
{
return BP_MeetCondition(_Owner);
}
protected:
UFUNCTION(BlueprintImplementableEvent)
bool BP_MeetCondition(AACTCharacter* _Owner);
};

节点中的CC_LeftMouseBtn(按下鼠标左键时进入分支)就是蓝图继承自AACTComboCondition,对BP_MeetCondition实现就是简单的判断按键是否输入:

生成连击树

有了这些定义就能配置生成一个角色的连击树了,首先是把攻击动画剪辑成多个动画蒙太奇:

每个蒙太奇中NotifyTrack都包含有动画开始结束,造成伤害,目押帧域这些必备的功能,当然可以按照需求加上播放特效,播放音效,震镜等效果.

不过每个Notify实际上是需要实现的,主要分为Tick和Duration两种,Tick继承自AnimNotify,Duration继承自AnimNotifyState,在蓝图中实现对应的接口就好.以目押帧域的ACTInputDuration为例,Recived_NotifyBegin打开Character的输入,Recived_NotifyEnd关闭Character输入:

接下来就是配置连击树节点:

并按照规划的路径分配好每个动画后续分支动画,组合成连击树:


ComboComponent

类似的,最后一步就是实现ComboComponent为Character提供Combo连击的能力了,定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class AACTCharacter;
class ACTGAME_API UACTComboComponent : public UActorComponent
{
GENERATED_BODY()
protected:
//节点是否满足连击条件
bool NodeMeetCondition(const FComboNodeStruct& node);
//进入节点,开始播放动画
void PlayNodeCombo(const FComboNodeStruct& node);
private:
void InitComboTree();
public:
void OnKeyDown(const FKey& key);
void ResetCurrentComboNode();
private:
UPROPERTY()
TObjectPtr<AACTCharacter> Owner;
UPROPERTY()
TArray<TObjectPtr<UACTComboTree>> ComboTreeList;
UPROPERTY()
TObjectPtr<UACTComboTree> ActiveComboTree;
UPROPERTY()
FComboNodeStruct CurrentComboNode;
};

每当有输入时,将根据当前节点以及对应的分支条件进行连招:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
void UACTComboComponent::OnKeyDown(const FKey& key)
{
if (Owner == nullptr || ActiveComboTree == nullptr)
return;
if (!Owner->GetCanInput())
return;
//空闲状态,检查第一个节点
if (CurrentComboNode.Combo==nullptr && NodeMeetCondition(ActiveComboTree->Root))
{
CurrentComboNode = ActiveComboTree->Root;
PlayNodeCombo(CurrentComboNode);
return;
}
//连击状态,检查连击树上的节点
if (CurrentComboNode.Combo != nullptr)
{
auto comboData = CurrentComboNode.Combo;
if (comboData == nullptr)
return;
bool currentNodeForward = false;
if (NodeMeetCondition(comboData->Next))
{
CurrentComboNode = comboData->Next;
currentNodeForward = true;
}
if (NodeMeetCondition(comboData->Branch))
{
CurrentComboNode = comboData->Branch;
currentNodeForward = true;
}
if (currentNodeForward)
{
//成功连招
Owner->AtkForward();
PlayNodeCombo(CurrentComboNode);
}
}
}

判断节点满足条件需要根据配置加载一次对应的蓝图,调用蓝图中实现的接口来得到返回值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
bool UACTComboComponent::NodeMeetCondition(FComboNodeStruct& node)
{
if (node.Combo == nullptr)
return false;
//无要求,直接触发
if (node.Condition == nullptr)
return true;
//加载蓝图
if (node.ConditionActor == nullptr)
{
TArray<AActor*> conditionActors;
UGameplayStatics::GetAllActorsOfClass(GetWorld(), node.Condition, conditionActors);
if (conditionActors.Num() > 0)
node.ConditionActor = Cast<AACTComboCondition>(conditionActors[0]);
else
node.ConditionActor = Cast<AACTComboCondition>(GetWorld()->SpawnActor(node.Condition));
}
//调用蓝图中实现的接口
if (node.ConditionActor == nullptr)
return false;
return node.ConditionActor->MeetCondition(Owner);
}

UEACTSkill

发表于 2022-06-14 | 分类于 UnrealEngine , ACT

ACT游戏中一个关键的模块就是搓招放技能,鬼泣5一大核心内容就是搓招放技能打出绚丽的连击.

尼禄的ShowDown需要短时间内快速输入4个键位释放

如果抽象成程序代码,搓招放技能该如何实现呢?

简单实现

最简单的思路,把连招每个输入做一次if判断,以图中ShowDown技能为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
OnKeyDown()
{
int inputState=0;
if(KeyDown(RightMouseBtn)&&inputState==0)
{
inputState=1;
return;
}
if(KeyDown(Forward)&&inputState==1)
{
inputState=2;
return;
}
if(KeyDown(LeftMouseBtn)&&inputState==3)
{
inputState=3;
return;
}
if(KeyDown(MiddleMouseBtn)&&inputState==4)
{
PlaySKill(ShowDown);
return;
}
inputState=0;
}

在每次接受输入的时顺序检测,直到技能的最后一个键位被输入.不过这样的做法太生硬了,每个技能都得程序实现.按照策划程序分工的原则,最好的做法应该是程序实现框架和原子,策划按照需求配置数据来实现功能.策划只需要配置类似于下方的连招表,程序就能自动读入数据执行.

连招 技能 优先级
AA 技能1 1
AB 技能2 2
ACA 技能3 3

数据准备

首先我们需要定义一个结构存储连招技能的数据,便于后续读取使用.

1
2
3
4
5
6
7
8
9
10
11
12
13
struct FInputSkillStruct
{
FKey Key; //输入键位
float InputTime=0.5f; //输入时间
};
class UACTSkillData
{
public:
FName SkillName = FName("None"); //技能名字
int Priority = 0; //优先级
TArray<FInputSkillStruct> InputSequence; //输入序列
TSubclassOf<AACTSkill> SkillToPerform; //释放的技能
};

UE实现了UPrimaryDataAsset类提供了基本的序列化反序列化功能,基础的数据类就可以继承UPrimaryDataAsset,这样就能生成资源文件,并在运行时读入内存.

上图就是快速输入两次W然后输入鼠标左键释放向前传送的技能配置数据.

输入处理

以连招W+W+LeftMouseButton为例,画出其执行逻辑,容易得出这是一个状态机模型.

1
2
3
4
5
6
7
8
9
graph LR

StateStart-->|KeyDown 'W' In 0.5s|State2
State2-->|KeyDown 'W' In 0.5s|State3
State3-->|KeyDown 'LeftMouseButton' In 0.5s|StateFinish

State2-->|KeyDown 'Other' or Timeout|StateStart
State3-->|KeyDown 'Other' or Timeout |StateStart

那么应该用有限状态机(finite-state machine,FSM)来实现?实际上还可以更简化一点,因为只有时间内正确输入推进到下一个状态,以及其他情况返回到起点,那么可以考虑只用一个链表保存所有输入,用一个指针cur指向当前状态,正确输入的Transition对应cur=cur->next,错误输入的Transition对应cur=head(当然用数组和index变量也是可以的),最终在UE的实现是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
UCLASS()
class ACTGAME_API UACTSkillInputSequenceItem : public UObject
{
GENERATED_BODY()

public:
FName InputKeyName;
float InputTime;

public:
void Init(FKey inputKey, float inputTime);

};

UCLASS()
class ACTGAME_API UACTSkillInputSequence : public UObject
{
GENERATED_BODY()

protected:
UPROPERTY()
FTimerHandle ResetTimeHandle;
UPROPERTY()
int NowSequenceIdx = -1;
UPROPERTY()
TArray<TObjectPtr<UACTSkillInputSequenceItem>> InputSequence;
public:
void InitBySkillData(TObjectPtr<UACTSkillData> skillData);
bool IsSequenceFinish();
void Reset();
void OnKeyDown(FKey key);

};

InitBySkillData的功能是从配置数据填充InputSequence:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void UACTSkillInputSequence::InitBySkillData(TObjectPtr<UACTSkillData> skillData)
{
if (skillData == nullptr || skillData->InputSequence.Num() == 0)
{
return;
}
NowSequenceIdx = 0;
for(auto & itemData : skillData->InputSequence)
{
UACTSkillInputSequenceItem* item = NewObject<UACTSkillInputSequenceItem>(this);
item->Init(itemData.Key, itemData.InputTime);
InputSequence.Add(item);
}
}

每次键盘输入,都会与当前的状态做判断,决定是否推进到下一个状态:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
void UACTSkillInputSequence::OnKeyDown(FKey key)
{
if (InputSequence.Num() <= 0 || IsSequenceFinish())
return;
auto nowSequenceItem = InputSequence[NowSequenceIdx];
if (nowSequenceItem == nullptr)
return;
if (nowSequenceItem->InputKeyName == key.GetFName())
{
GetWorld()->GetTimerManager().ClearTimer(ResetTimeHandle);
++NowSequenceIdx;
GetWorld()->GetTimerManager().SetTimer(ResetTimeHandle, this, &UACTSkillInputSequence::Reset, 1.0f, false, nowSequenceItem->InputTime);
}
else
{
Reset();
}
}

Skill定义

Skill是技能对应的实体,继承自UE的Actor基类.也就是说,释放技能会在场景中生成一个Actor,这个Actor执行具体的技能逻辑,类的定义和实现都十分简单:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class AACTCharacter;

UCLASS()
class ACTGAME_API AACTSkill : public AActor
{
GENERATED_BODY()
public:
void Perform(AACTCharacter* _Owner)
{
PerformSkill(_Owner);
BP_PerformSkill(_Owner);
}
protected:
virtual void PerformSkill(AACTCharacter* _Owner);
UFUNCTION(BlueprintImplementableEvent)
void BP_PerformSkill(AACTCharacter* _Owner);
};

可以看到Perform的实现其实是分别调用了C++的PerformSkill以及提供给蓝图实现接口的BP_PerformSkill.这里的设计思路是,如果技能复杂,或是计算消耗太大,可以继承AACTSkill类C++实现.如果是简单的技能逻辑,可以用蓝图来做.

使用的方式则是SpawnActor后调用Perform接口.

1
2
PlayingSkill = Cast<AACTSkilI>(GetWorld()->SpawnActor(readySkill->SkillToPerform));
PlayingSkill->Perform(Cast<AACTCharacter>(GetOwner()));

一个简单的向前传送技能蓝图实现:

大意是将调用Telport接口,将Actor传送到Actor.LocalLocation+Actor.Rotation.Foward*Length的位置.

SkillComponent

所有需要的前置模块都实现后就是组装实现SkillComponent为Character提供搓招能力了.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class ACTGAME_API UACTSkillComponent : public UActorComponent
{
GENERATED_BODY()
protected:
UPROPERTY()
TMap<TObjectPtr<UACTSkillData>, TObjectPtr<UACTSkillInputSequence>> SkillMap;
UPROPERTY()
TObjectPtr<AACTSkilI> PlayingSkill;
protected:
void InitSkillMap();
void AddSkill(TObjectPtr<UACTSkillData> skillData);
public:
bool IsSkillPlaying();
void OnKeyDown(FKey& key);
UFUNCTION(BlueprintCallable)
void OnSkillEnd();
};

InitSkillMap其实就是用序列化成资源文件的SkillData填充SkillMap,逻辑较为简单:

1
2
3
4
5
6
7
8
9
10
11
void UACTSkillComponent::InitSkillMap()
{
UACTAssetManager& manager = UACTAssetManager::Get();
TArray<FPrimaryAssetId> skillAssetIds;
manager.GetPrimaryAssetIdList(manager.SkillType, skillAssetIds);
for (auto& id : skillAssetIds)
{
TObjectPtr<UACTSkillData> skillData = manager.GetPrimaryAssetObject<UACTSkillData>(id);
AddSkill(skillData);
}
}

关键的逻辑在于OnKeyDown,每当有输入时,将触发SkillMap中所有技能序列的OnKeyDown,之后找到一个优先级最高并且已经输入完毕的技能释放:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
void UACTSkillComponent::OnKeyDown(FKey& key)
{
if (SkillMap.Num() == 0 || IsSkillPlaying())
return;
TObjectPtr<UACTSkillData> readySkill = nullptr;
//find skill to perform
for (auto iter = SkillMap.begin(); iter != SkillMap.end(); ++iter)
{
auto skillData = iter->Key;
auto skillSeq = iter->Value;
skillSeq->OnKeyDown(key);
if (skillSeq->IsSequenceFinish())
{
skillSeq->Reset();
if (readySkill == nullptr || skillData->Priority > readySkill->Priority)
readySkill = skillData;
}
}
if (readySkill != nullptr)
{
PlayingSkill = Cast<AACTSkilI>(GetWorld()->SpawnActor(readySkill->SkillToPerform));
PlayingSkill->Perform(Cast<AACTCharacter>(GetOwner()));
}
}

UEController

发表于 2022-06-12 | 分类于 UnrealEngine

分析现有的第一人称射击游戏可以发现,无论是早期的CS,Half-Life还是最近的使命召唤,战地.基本的操作模式几乎一样.玩家角色的面向永远跟随鼠标/控制器的旋转,移动基于视角的面向进行.

泰坦陨落

而第三人称游戏的操作模式有些变化,大体上可以分为两种模式.

一种为角色朝向输入方向运动,大部分自由视角的游戏都是这种操作模式.

一种为角色朝向相机的八方向运动,锁定敌人的情况下会是这种操作模式(艾尔登法环,黑暗之魂的锁定系统).

而无论角色朝向相机与否,角色的移动总是基于相机方向进行.

在大多数游戏需要的移动控制需求都十分类似的情况下,非常适合在引擎中实现一套功能完善的控制系统,避免游戏开发者重复造轮子.Unreal在其GamePlay框架下实现了一套大部分情况下能满足开发者需求的控制系统.

PlayerController

首先可以看官方文档对PlayerController的解释:

PlayerController(玩家控制器) 是Pawn和控制它的人类玩家间的接口。PlayerController本质上代表了人类玩家的意愿。当您设置PlayerController时,您需要考虑的一个事情就是您想在PlayerController中包含哪些功能及内容。您可以在 Pawn 中处理所有输入, 尤其是不太复杂的情况下。但是,如果您的需求非常复杂,比如在一个游戏客户端上的多玩家、或实时地动态修改角色的功能,那么最好在PlayerController中处理输入。在这种情况中,PlayerController决定要干什么,然后将命令(比如”开始蹲伏”、”跳跃”)发布给Pawn。

与PlayerController相对的是AIController,两者都继承自Controller.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
classDiagram
class AController{
ControlRotation:FRotator
}

class AAIController{
RunBehaviorTree()
}

class APlayerController{
RotationInput:FRotator
AddPitchInput()
AddYawInput()
AddRollInput()
}

AController <|--AAIController
AController <|--APlayerController

AIController提供行为树管理AI控制的Pawn,PlayerController则接受输入控制玩家角色Pawn.

PlayerContoller中成员方法众多,这里我们只关注ControlRotaion这个属性.

ActorRotation与ControlRotation

ActorRotaion容易理解,无论是在第一人称还是第三人称的游戏中,玩家的角色总是会有一个面向,这个面向在Unreal中被称为ActorRotaion.

图中箭头指向的方向可以用来标记这个朝向.

ControlRotation的含义是什么呢?实际上就是视角的面向,在第三人称无锁定自由视角的操作模式下,角色的面向与视角的面向在大部分时候并不一致,玩家可以自由控制视角的面向,只需要转动视角既能让角色正对着屏幕,也能让角色背对屏幕.这个过程实际上就是改变了ControlRotation,并且让相机POV根据新的ControlRotation进行位置和旋转面向的调整:


图中的红线方向是角色的面向,蓝线则是转动视角时视角的面向,实现的逻辑如下:

ControlRotation更新逻辑

ControlRotation接受玩家输入的接口是

  • APlayerController::AddPitchInput(float val)
  • APlayerController::AddYawInput(float val)
  • APlayerController::AddRollInput(float val)

一般来说只需要考虑Pitch和Yaw的输入,用鼠标作为输入为例,Pitch对应上下瞄准,Yaw对应左右瞄准,同理可以推及手柄(右摇杆上下左右移动)/手机屏幕(上下左右划屏幕).

在ACharacter::SetupPlayerInputComponent的执行过程中可以对输入设备的输入进行回调函数绑定.

1
2
3
4
5
6
7
8
9
10
11
12
13

void AACTCharacter::SetupPlayerInputComponent(UInputComponent* PlayerInputComponent)
{
Super::SetupPlayerInputComponent(PlayerInputComponent);
...
PlayerInputComponent->BindAxis(TEXT("Turn"), this, &ACharacter::AddControllerYawInput);
PlayerInputComponent->BindAxis(TEXT("LookUp"), this, &ACharacter::AddControllerPitchInput);
}

void APlayerController::AddYawInput(float Val)
{
RotationInput.Yaw += !IsLookInputIgnored() ? Val : 0;
}

接受到输入后ControlRotation并不会立刻更新,而是等待PlayerController的Tick调用,结束后会将RotationInput中累积的数据清空.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void APlayerController::UpdateRotation( float DeltaTime )
{
// Calculate Delta to be applied on ViewRotation
FRotator DeltaRot(RotationInput);

FRotator ViewRotation = GetControlRotation();

if (PlayerCameraManager)
{
PlayerCameraManager->ProcessViewRotation(DeltaTime, ViewRotation, DeltaRot);
}
...
SetControlRotation(ViewRotation);
...
}

Update Camera By ControlRotation

在分析相机的文章中有提到过SpringArmComponent,在其Camera Settings的栏位中有UsePawnControlRotation,Inherit Pitch,Inherit Yaw,Inherit Roll四个属性,勾选这些选项后,子节点的相机就能根据鼠标的转动调整位置和朝向,为PlayerCameraManager提供合适的POV.

这些选项体现在代码中就是

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
FRotator USpringArmComponent::GetTargetRotation() const
{
FRotator DesiredRot = GetDesiredRotation();
if (bUsePawnControlRotation)
DesiredRot = PawnViewRotation;
const FRotator LocalRelativeRotation = GetRelativeRotation();
if (!bInheritPitch)
DesiredRot.Pitch = LocalRelativeRotation.Pitch;
if (!bInheritYaw)
DesiredRot.Yaw = LocalRelativeRotation.Yaw;
if (!bInheritRoll)
DesiredRot.Roll = LocalRelativeRotation.Roll;
return DesiredRot;
}

void USpringArmComponent::UpdateDesiredArmLocation(bool bDoTrace, bool bDoLocationLag, bool bDoRotationLag, float DeltaTime)
{
FRotator DesiredRot = GetTargetRotation();
...
// Now offset camera position back along our rotation
DesiredLoc -= DesiredRot.Vector() * TargetArmLength;
// Add socket offset in local space
DesiredLoc += FRotationMatrix(DesiredRot).TransformVector(SocketOffset);
FVector ResultLoc = DesiredLoc;
/ Form a transform for new world transform for camera
FTransform WorldCamTM(DesiredRot, ResultLoc);
// Convert to relative to component
FTransform RelCamTM = WorldCamTM.GetRelativeTransform(GetComponentTransform());
// Update socket location/rotation
RelativeSocketLocation = RelCamTM.GetLocation();
RelativeSocketRotation = RelCamTM.GetRotation();
UpdateChildTransforms();
}

总结代码的逻辑就是

相机的位置 = 父节点位置 - ControlRotation * 弹簧臂长

相机旋转 = ControlRotation

经过弹簧臂的逻辑更新后,相机的朝向永远正对视角的朝向,位置则是保持在以角色为球心,弹簧臂长为半径的球面上,这样也就实现了第三人称下自由视角模式的摄像机功能.

锁定模式下的人物朝向相机运动逻辑也容易理解,ControlRotation不再接受输入,ControlRotation与ActorRotation都固定为人物与锁定目标的连线.

CharacterMovement By ControlRotation

前面提到过,人物的移动无论是在哪种操作模式下(包括第一人称),前进方向永远是相机的朝向,所以移动的逻辑也十分通用.

1
2
3
4
5
6
7
8
void AACTCharacter::MoveForward(float val)
{
if ( val == 0.0f || !CanMove)
return;
const FRotator Rotation = GetControlRotation();
const FRotator YawRotation(0, Rotation.Yaw, 0);
AddMovementInput(YawRotation.Vector(), val);
}

AddmovementInput是引擎中提供的接口,每次AddMovementInput,经过多层跳转最终都会回到Pawn::Internal_AddMovementInput,累加输入的方向.

1
2
3
4
5
6
7
void APawn::Internal_AddMovementInput(FVector WorldAccel, bool bForce /*=false*/)
{
if (bForce || !IsMoveInputIgnored())
{
ControlInputVector += WorldAccel;
}
}

每次UCharacterMovementComponent::TickComponent时,都会消耗掉当前帧累加的ControlInputVector(是不是很像生产者消费者模型?):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
UCharacterMovementComponent::TickComponent(...)
{
...
InputVector = ConsumeInputVector();
...
//Client Owner端和Server端进行实际的移动操作
if (CharacterOwner->GetLocalRole() > ROLE_SimulatedProxy)
{
//Client Owner根据InputVector进行移动
if (CharacterOwner->IsLocallyControlled()||...)
{
ControlledCharacterMove(InputVector, DeltaTime);
}
}
else if (CharacterOwner->GetLocalRole() == ROLE_SimulatedProxy)
{
...
SimulatedTick(DeltaTime);
}
}

UCharacterMovementComponent::ControlledCharacterMove会将InputVector映射到[0,MaxAcceleration]之间,用这个加速度本地预表现后再发送给Server端进行校验(PerformMovement是实际修改位置的逻辑):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
FVector UCharacterMovementComponent::ScaleInputAcceleration(const FVector& InputAcceleration) const
{
return GetMaxAcceleration() * InputAcceleration.GetClampedToMaxSize(1.0f);
}

void UCharacterMovementComponent::ControlledCharacterMove(const FVector& InputVector, float DeltaSeconds)
{
...
Acceleration = ScaleInputAcceleration(ConstrainInputAcceleration(InputVector));
if (CharacterOwner->GetLocalRole() == ROLE_Authority)
{
//权威端直接PerformMovement,这是实际修改位置的逻辑
PerformMovement(DeltaSeconds);
}
else if (CharacterOwner->GetLocalRole() == ROLE_AutonomousProxy && IsNetMode(NM_Client))
{
//否则预表现移动并保存本次Move(用于后续拉扯后前滚到最新帧),再发送给Server校验
ReplicateMoveToServer(DeltaSeconds, Acceleration);
}
}

UCharacterMovementComponent::ReplicateMoveToServer写得实在太长,只能写个大概的理解了(忽略了为了节约带宽而写的逻辑…有点太复杂了):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
UCharacterMovementComponent::ReplicateMoveToServer(float DeltaTime, const FVector& NewAcceleration)
{
//保存本次Move part1
FSavedMovePtr NewMovePtr = ClientData->CreateSavedMove();
NewMove->SetMoveFor(CharacterOwner, DeltaTime, NewAcceleration, *ClientData);
...
//Local预表现Move
Acceleration = NewMove->Acceleration.GetClampedToMaxSize(GetMaxAcceleration());
PerformMovement(NewMove->DeltaTime);
...
//保存本次Move part2
ClientData->SavedMoves.Push(NewMovePtr);
//发包让Server PerformMovement
CallServerMove(NewMove, OldMove.Get());
}

UCharacterMovementComponent::CallServerMove会RPC调用Server的代码,忽略掉其他逻辑,最终也会在Server端用本次Move的加速度PerformMovement:
CallServerMove->ServerMoveDual->(Client Call Server RPC)ServerMoveDual_Implementation->MoveAutonomous

1
2
3
4
5
6
7
8
void UCharacterMovementComponent::MoveAutonomous(...)
{
...
Acceleration = ConstrainInputAcceleration(NewAccel);
Acceleration = Acceleration.GetClampedToMaxSize(GetMaxAcceleration());
PerformMovement(DeltaTime);
...
}

这也就是在联网模式下,一次输入所需要执行的逻辑.

UECamera

发表于 2022-06-11 | 分类于 UnrealEngine

在游戏中,摄像机决定了玩家以什么样的方式来观察游戏世界.游戏的类型有多种,第一人称射击, 第三人称动作/射击,RTS,横版游戏…各种游戏类型对相机的实现也不一样.

UE GamePlay框架下的相机模块功能完备,大部分时候开发者不需要添加代码就可以实现各种类型游戏中的相机系统.

Point Of View

UE的相机模块有个关键的概念,Point Of View(POV),也就是玩家的视点,其中有位置,旋转,FOV,AspectRation等各种决定渲染变换矩阵的属性.

1
2
3
4
5
6
7
8
9
10
11
12
struct FMinimalViewInfo
{
FVector Location;
FRotator Rotation;
float FOV;
float DesiredFOV;
float OrthoWidth;
float OrthoNearClipPlane;
float OrthoFarClipPlane;
float AspectRatio;
...
}

在游戏世界中,POV可能会有多个,但只有一个POV会成为ViewTarget,也就是最终的虚拟相机属性.更直白的说法就是,可以在游戏世界中架设多台相机,但最终渲染结果只会用其中一个相机的摄影结果展示给玩家,这个ViewTarget的定义如下:

1
2
3
4
5
struct FTViewTarget
{
TObjectPtr<AActor> Target;
FMinimalViewInfo POV;
}

CameraComponent

CameraComponent提供了一个POV,一般而言作为ViewTarget的CameraComponent Location,Rotation,FOV等属性会直接传递给POV,可以看引擎中对CameraComponet::GetCameraView()的实现:

1
2
3
4
5
6
7
8
void UCameraComponent::GetCameraView(float DeltaTime, FMinimalViewInfo& DesiredView)
{
...
DesiredView.Location = GetComponentLocation();
DesiredView.Rotation = GetComponentRotation();
DesiredView.FOV = bUseAdditiveOffset ? (FieldOfView + AdditiveFOVOffset) : FieldOfView;
...
}

第三人称游戏,通常会在玩家角色蓝图上添加CameraComponent,引擎会优先使用角色下的相机作为ViewTarget.

CameraComponent并不是以Character作为父节点,而是以SpringArmComponent作为父节点.

Spring Arm Component

这个组件的作用是将子节点固定在父节点的相对位置,当玩家行走,控制转向时,作为子节点的CameraComponent会跟随着移动转向.

这里看起来只需要放在子节点然后调节一个RelativePosition就能做到,为什么要多此一举用一个SpringArmComponent呢?实际上SpringArmComponent更重要的功能是检测相机是否与场景发生碰撞,在相机与场景发生交叉时候,弹簧臂将缩短父子节点的距离。遮挡消失后,距离又会变回设置的TargetArmLength.

除此之外,SpringArmComponent还提供了选项用ControlRotation来控制摄像机旋转,以及为了体现速度感和瞬移的Lag插值移动相机功能.

在这些组件的帮助下,第一人称的游戏可以将相机固定在人物的眼睛位置,第三人称则是把相机放在人物背后一段距离,第一/第三人称的切换也可以通过调整SpringArmComponent的属性来实现.RTS或者横版游戏的相机,也可以通过放置一个隐藏模型的Pawn在场景中实现.

相机切换

游戏开发中只有玩家身上的相机在有些情况可能满足不了需求.譬如进入副本播放入场动画,到达指定位置触发实时演算的过场可能就需要在多个POV进行切换,UE中实现相机切换也十分简单,在场景中放置多个CameraComponent,通过蓝图或者C++代码进行切换:

(PlayerController(玩家控制器) 是Pawn和控制它的人类玩家间的接口)

1
2
3
4
5
6
7
APlayerController::SetViewTarget(class AActor* NewViewTarget, struct FViewTargetTransitionParams TransitionParams)
{
if (PlayerCameraManager)
{
PlayerCameraManager->SetViewTarget(NewViewTarget, TransitionParams);
}
}

为了让切镜不突兀,UE也预设了一些切换时的Blend效果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
enum EViewTargetBlendFunction
{
/** Camera does a simple linear interpolation. */
VTBlend_Linear,
/** Camera has a slight ease in and ease out, but amount of ease cannot be tweaked. */
VTBlend_Cubic,
/** Camera immediately accelerates, but smoothly decelerates into the target. Ease amount controlled by BlendExp. */
VTBlend_EaseIn,
/** Camera smoothly accelerates, but does not decelerate into the target. Ease amount controlled by BlendExp. */
VTBlend_EaseOut,
/** Camera smoothly accelerates and decelerates. Ease amount controlled by BlendExp. */
VTBlend_EaseInOut,
/** The game's camera system has already performed the blending. Engine should not blend at all */
VTBlend_PreBlended,
VTBlend_MAX,
};

PlayerController实际上会将SetViewTarget的操作转发给PlayerCameraManager执行,也就是唯一的摄像机管理类.

PlayerCameraManager

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class APlayerCameraManager : public AActor
{
/** Current ViewTarget */
UPROPERTY(transient)
struct FTViewTarget ViewTarget;

/** Pending view target for blending */
UPROPERTY(transient)
struct FTViewTarget PendingViewTarget;

/** Time remaining in viewtarget blend. */
float BlendTimeToGo;

/** Current view target transition blend parameters. */
struct FViewTargetTransitionParams BlendParams;

//震镜之类的效果在这里
UPROPERTY(transient)
TArray<TObjectPtr<UCameraModifier>> ModifierList;
}

从PlayerCameraManager的定义来看,PlayerCameraManager决定了观察游戏世界最终的视角是怎样的,除了提供接口用于切换镜头,摄像机效果如震镜,镜头特效也是在其中处理,其更新最终ViewTarget的堆栈如下:

在UWorld中的所有Actor的Tick逻辑执行完毕后,引擎才会驱动PlayerController->PlayerCameraManager更新ViewTarget.

UCameraComponent::GetCameraView()就是用CameraComponent的属性的属性覆写ViewTarget中的POV数值.

更新POV后,一些可能会改动相机最终属性的效果(震镜)会在其后处理,最后会处理镜头效果.

1
2
3
4
5
6
7
8
9
APlayerCameraManager::UpdateViewTarget()
{
//更新POV
...
//震镜
ApplyCameraModifiers(DeltaTime, OutVT.POV);
//镜头效果
UpdateCameraLensEffects(OutVT);
}

这些效果也可以在合适的时机用蓝图或者C++代码添加.

UEAnimation

发表于 2021-09-22 | 分类于 UnrealEngine

Animation Blueprint

AnimationBP类似于Unity中的Animator,主要功能就是用状态机的形式播放动画.

打开动画蓝图的编辑窗口后,左下角可以看到EventGraph,AnimationGraph,AnimationLayers,Functions,Macros,Variables,EventDispatchers几个部分,比较关键的是EventGraph和AnimationGraph两个部分.

EventGraph

初始工程角色的动画蓝图中EventGraph每帧都会从Pawn的Movement组件中获取角色移动状态,并根据这些状态的数值来设置动画蓝图中的预设变量(Variables).可以理解为根据游戏逻辑设置动画蓝图的参数,AnimationGraph中的状态机就会根据这些变量选取合适的动画进行播放.

上图就是从Pawn的MovementComponent中获取速度和下落状态,并设置sInAir,Speed变量.

AnimationGraph

最简单的AnimationGraph,一个状态机决定最终播放的动画

展开状态机,可以看到状态机中状态的切换规则

每个State都会输出一个动画,如何输出也可以自定义蓝图来实现.

Idle/Run这个状态的输出动画,就是用EventGraph中设置的Speed变量对多个动画进行融合:

除了State可以用蓝图来定义输出,State间的Transition条件也可以用蓝图来描述:

可以用两个变量的异或值来决定是否切换状态.

Anim Montage

动画蒙太奇个人觉得是个巧妙的设计.设想一下,用状态机实现了人物的行走跳跃翻滚后,如果想要实现按下某个键位后播放技能,应该怎么加入技能动画呢?

如果只使用状态机,可能会这样实现:

在Variables中添加变量Skill,分别用Skill=1,Skill=2,Skill=3进入对应的状态播放技能动画.实际上应该也有项目是这样做的)

在技能比较少,状态机相对简单时,这样实现无可厚非.当状态机变得复杂时,技能状态与常驻状态耦合在同一个图中,开发新内容时需要改动AnimationGraph,技能变多时密密麻麻的连线也会增加理解成本.(不过用Unity的Animator只能这样实现,可以说是很难受了)

AnimationGraph中的蒙太奇插槽就很好的解决了这个问题.

StateMachine和OutputPose中的节点就是蒙太奇插槽,’DefaultSlot’是插槽的命名,是可以自定义的.正常情况可以认为这个节点不存在,此时

1
OutputPose = StateMachine

如果使用C++代码或者蓝图调用接口ACharacter::PlayAnimMontage(),OutputPose就会变成PlayAnimMontage中传入的动画:

1
OutputPose = Slot'DefaultSlot' Animation

也就是说,蒙太奇插槽能用指定动画覆盖在这个节点之前的所有动画.用这个功能来实现新增技能就很方便了,只需要新增一个AnimMontage,使用蓝图或者C++代码在合适的时机调用PlayAnimMontage接口播放,不需要更改AnimationGraph,逻辑分工也十分清晰.

AnimMontage则是由一个或多个动画片段组成,一个简单的蒙太奇示例:

大致可以分为三个部分:

  • 1:指定插槽播放,蒙太奇资源必须预设好其在AnimationGraph中的对应的Slot,播放蒙太奇时会覆盖Slot前的动画.
  • 2:动画回调事件,可以在动画回调事件中进行诸如播放音效,粒子效果计算伤害等功能.
  • 3:动画片段,一个或多个AnimationSequence拼接而成,可以指定从什么位置播放.

ACT游戏中的搓招技能,连招也是通过播放蒙太奇来实现.

SubstanceExplorer

发表于 2021-06-27 | 分类于 tools

Substance Designer

在听过几场关于程序化内容生成的技术分享后,对PCG管线的工具链产生了一些兴趣,就尝试着学了一点Substance Designer。

SD主要用于生成程序化的纹理贴图,在广泛使用的PBR着色模型下,SD通常会有下面四种输出:

分别是基础色、法线贴图、粗糙度和金属度,与虚幻引擎的Metallic/Roughness工作流可以完美对接。

SD的工作内容就是用数据流编程的方式来生成这些贴图,工具默认提供了很多方便使用的节点:

最基础也是最常用的就是各种Generator了,用于生成各种类型的高度图:

每种类型的节点都会提供很多参数供调节:

调节旋转参数后可以生成这样的图案:

另一类则是运算节点,可以理解为都是Pixel Processor的子类:

Pixel Processor与渲染中的Fragment Shader十分类似,都是对所有像素执行一个函数,并输出结果。

1
2
foreach pixel_in in input
pixel_out = ProcessFunc(pixel_in1,pixel_in2...)

以节点Blend为例:

这个节点的作用是将Background和Foreground通过操作参数(add,sub,multiply…)混合得到结果,Blend的ProcessFunc可以简单的表示为:

1
ProcessFunc = Background op (Foreground * Opacity)

类似的,Transformation 2D节点则是将输入的节点进行位置变换,可以进行旋转平移缩放等操作:

其实就是对每一个像素进行了矩阵乘法:

$P^·=Matrix_{transform} · P$

这些节点类似于编程语言中的各种原子操作,实现这些基本原子后,就可以组合实现各种纹理。

以一个最简单的金属材质为例:

这些节点组合就会生成这样一张污渍被刮花的金属贴图:

基础色直接用金属的颜色与噪声图进行混合,看起来就像是金属被氧化了。法线贴图则是用进行一些加减操作后的高度图来实现指定形状的图案。粗糙度贴图则是由一张噪声贴图减去高度图,这样做的话金属突出的部分粗糙度较低,比较符合暴露在外的金属经常被摩擦污渍较少,同时噪声中黑色的部分粗糙度也比较低,看起来就像是污渍被清掉了。

这些数据流图也可以简单的用几句代码来描述:

1
2
3
4
5
6
7
8
9
10
distanceMap = DistanceMap(TileGenerator)
rotate45 = rotate(distanceMap)
rotate_45 = rotate("distanceMap)
detailMap = max(rotate45-rotate_45,0)

BaseColor = noise1*uniformColor*opacity;
Normal = detailMap
Roughness = noise2-detailMap*opacity
Metallic = 1

配合游戏引擎使用

因为Unity2017和SD配合得不太好,最近也在学习UE相关知识,就用UE来配合使用了。

SD中可以选择暴露想要在引擎里调节的参数:

导出sbsar到引擎中就可以任意改变这个参数来生成不同的纹理:

下面就是distance参数被分别设置成1和3的结果。

一些思考

Node-Based的图形化编程被广泛用于游戏开发的各个过程中,程序化内容生成工具如Houdini,Substance Designer都是基于此,游戏引擎中UE的材质编辑器和蓝图,Unity的ShaderGraph也都是用连连看的方式来实现功能。一方面图形化编程降低了门槛,不需要了解编程知识也可以做实际上写代码的工作,另一方面图形化后逻辑的可读性以及可维护性应该是不如程序代码的。
曾经在网上看到过这样精美的程序化纹理:

然而它的SD节点图是这样的:

将一些通用逻辑封装成块,或者用代码实现是不是更好的选择?

123<i class="fa fa-angle-right"></i>

21 日志
9 分类
© 2022 chaosrings
由 Hexo 强力驱动
|
主题 — NexT.Gemini v5.1.4