[译文]蓝图 VS. C++

原文地址: Blueprints vs. C++ : How They Fit Together and Why You Should Use Both

youtube: Blueprints vs. C++: How They Fit Together and Why You Should Use Both

bilibili: 蓝图 vs C++ 它们如何一起工作 为什么你应该一起使用

简介

虚幻引擎提供多种游戏编程方式:你可以使用C++ 或者 蓝图(Blueprints), 也可以使用一些脚本语言(比如: Python、Lua、TypeScript ), 本文讨论是C++和蓝图。C++和蓝图之间的差异很大,C++是一种基于文本的编程语言; 而蓝图像是专门为更上层的游戏流程而量身定制:其编程方式是将事件、流程控制、函数调用等用图形节点串联起来,通过编辑器就可以定义变量、方法和接口

C++和蓝图编程示例

C++和蓝图的差异这么大,你可能会疑问:“我应该用C++还是蓝图来开发游戏呢?” 其实你不应该这么问,虚幻引擎本身的设计就是让C++和蓝图互补,所以你应该问:“C++和蓝图分别适合用在什么地方?”

C++和蓝图的共同点

如果我希望在游戏开始时生成一个 Actor , 用C++实现方式如下:

void ACoyote::BeginPlay()
{
    Super::BeginPlay();

    if (bSpawnAnvil)
    {
        const FVector SpawnOffset(100.0f, 0.0f, 1500.0f);
        const FVector SpawnLocation = GetActorTransform().TransformPosition(SpawnOffset);
        const FTransform SpawnTransform(FQuat::Identity, SpawnLocation);

        FActorSpawnParameters SpawnInfo;
        SpawnInfo.Owner = this;

        AAnvil* Anvil = GetWorld()->SpawnActor<AAnvil>(AnvilClass, SpawnTransform, SpawnInfo);
        if (Anvil)
        {
            Anvil->BeginFalling();
        }
    }
}

而蓝图的实现方式如下:

蓝图实现Spawn Actor

这两种方式看起来差异很大,实际上执行的结果一致。实现一个功能你可以使用 C++ 也可以使用蓝图,无论你使用哪一种方式,你其实都是在编程。编程不是按照语法规则编写代码,而是定义程序运行时的行为,因此我们大部分工作都是在搭建好的项目框架内创建新的对象,定义其行为以及与其他对象之间的交互规则。换句话说,编程其实就是在设计软件

设计理念:高级与低级

当你设计一个像《堡垒之夜》这种规模宏大的游戏软件时,从垂直的角度去思考问题会好一些;通常我们的目标是实现一些复杂炫酷的高级功能,那么就需要将高级功能拆分为很多个可以实现的基础功能

如果你想你的游戏有个超酷的导弹发射器,它发射的导弹能追踪敌人并爆炸,要怎么实现呢?万丈高楼平地起,需要实现这些炫酷的功能那必须要有坚实的底层基础,而虚幻引擎、项目框架以及一些第三方插件就是我们的底层基础

而作为游戏开发工程师(码农)的工作内容,就是把这个设计过程中缺失的实现细节填补完整

设计示例:武器系统

对于武器系统,可以先继承引擎提供的类型来构造我们需要的类,然后决定每个类的具体职责以及和其他对象的交互方式,以及它们需要用到哪些引擎底层功能

设计武器类

例如:Weapon 类需要处理玩家的输入、弹药的管理、开火以及冷却等逻辑;以及酷炫的武器皮肤、枪花特效等可视化内容。而为了实现这些,我们需要用的引擎底层功能则有如下:

  • 为了实现武器的开火功能,我们需要使用 APawn 提供的输入组件 InputComponent
  • 为了检测武器是否击中目标,我们需要使用引擎提供的射线检测 LineTraceSingleByChannel
  • 为了让受击目标响应被击中,我们需要使用内置的伤害系统 TakeDemage
  • 为了让这一切表现的更加酷炫,我们就需要引擎提供的渲染、动画、特效以及声音系统

以下是C++实现武器的射线检测功能

void AWeapon::RunWeaponTrace(const FTransform&amp; MuzzleTransform, float TraceDistance)
{
    const FVector TraceStart = MuzzleTransform.GetLocation();
    const FVector TraceEnd = TraceStart + (MuzzleTransform.GetUnitAxis(EAxis::X) * TraceDistance);
    const FCollisionQueryParams QueryParams(TEXT("WeaponTrace"), false, this);

    FHitResult Hit;
    if (GetWorld()->LineTraceSingleByChannel(Hit, TraceStart, TraceEnd, ECC_WeaponFire, QueryParams))
    {
        if (Hit.Actor.IsValid())
        {
            const float DamageAmount = 1.0f;
            const FVector ShotFromDirection = (TraceEnd - TraceStart).GetSafeNormal();
            const TSubclass<UDamageType>DamageTypeClass = UDamageType_WeaponFire::StaticClass();
            const FPointDamageEvent DamageEvent(DamageAmount, Hit, ShotFromDirection, DamageTypeClass);
            Hit.Actor->TakeDamage(DamageAmount, DamageEvent, OwningController, this);
        }
    }
}

蓝图也可以轻松实现相同的功能:

蓝图实现射线检测

设计理念:脚本与编程

即便是设计一个简单的功能,比如武器:在不同的抽象层级上,都有不同的问题需要去解决。在最底层可能遇到的问题是:“如何向操作系统申请存储武器对象所需的内存空间?”,在最高层的表现上则会有这类问题:“当我被敌人集火时,我身上应该显示哪种紫色阴影?”

那些底层问题通常属于引擎编程领域,引擎编程涉及所有游戏开发需要的核心技术,而不局限于某一类型的游戏。当我们开始基于这些核心技术来开发特定游戏,并实现这个游戏的玩法时,我们就进入了游戏编程领域

基于搭建好的项目框架,我们还需做很多工作来充实玩家的游戏体验,这些工作我们称之为脚本。脚本编写侧重于更高层次的功能,例如:游戏的整体流程和进度、不同游戏对象之间的交互、或者某个具体游戏对象的外观和行为方式等

因此,”通常“编程”是指解决一些底层的问题,而“脚本”则是指在高级系统之上填充细节

C++ 和蓝图作为编程和脚本

C++ 是一门编程语言,而蓝图则是脚本系统。因此C++适合实现游戏底层系统,而蓝图则更适合用于定义高级行为、交互、资产整合以及一些需要微调的装饰性细节等。一般来说,C++和蓝图大多都是按照这样的界限被使用

C++和蓝图界限

关于虚幻引擎你要明白一点就是: 它并没有为你清晰的划分这条界限,也没指定你必须使用C++或蓝图来解决某些问题,你需要自己划分这条界线。你大可以在前期利用蓝图快速搭建游戏原型,然后在游戏设定变得清晰的时候,再将部分蓝图重构为C++

虚幻引擎的设计旨在提供这种灵活性:

  • 引擎不存在单独的“脚本API”——无论你使用C++还是蓝图,你都在以几乎相同的方式使用同一套引擎系统
  • C++和蓝图的高度集成使得两者之间更加容易互通,你可以轻松在C++和蓝图之间切换
  • 所有蓝图内容都可以”翻译”成 C++

以后如果有人问你 “ 项目开发中,C++或者蓝图哪个更好?” 你就可以对他笑而不语

性能: 编译 C++ / 蓝图

译者注:本段与原文有很大差异,因为原文阐述了很细的点,本文只浅显阐述基本内容

在C++和蓝图都可选的情况下,选择哪一种方式实现功能通常需要从多个方面来考虑,经常被提到的一个重要因素是性能;当你编写C++代码时,最后得到一个 .cpp 格式的文本文件:

void AMissile::Tick(float DeltaSeconds)
{
    Super::Tick(DeltaSeconds);

    const float OffsetForward = MovementSpeed * DeltaSeconds;
    const FVector Offset(OffsetForward, 0.0f, 0.0f);
    AddActorLocalOffset(Offset);
}

从源码构建项目时,C++代码会被编译为机器码(也就是能够直接在CPU上运行的处理器指令列表)

  ; AMissile::Tick
    push 40 53
     sub 48 83 EC 60
  movaps 0F 29 74 24 50
     mov 48 8B D9
  movaps 0F 28 F1
            ; AActor::Tick
    call FF 15 F9 9B 00 00
   mulss F3 0F 59 B3 F0 02 00 00
     lea 48 8D 54 24 30
     mov C7 44 24 48 00 00 00 00
     xor 45 33 C9
     mov 8B 44 24 48
   xorps 0F 57 D2
     xor 45 33 C0
     mov 89 44 24 38
  movaps 0F 28 C6
     mov C6 44 24 20 00
unpcklps 0F 14 C2
     mov 48 8B CB
   movsd F2 0F 11 44 24 30
            ; AActor::AddActorLocalOffset
    call FF 15 01 A0 00 00
  movaps 0F 28 74 24 50
     add 48 83 C4 60
     pop 5B
     ret C3

而使用蓝图最终会得到一张由一堆节点组成的事件图表,并保存在一个蓝图资产中

蓝图也会被编译,但不会编译为机器码。脚本编译器会将蓝图编译成脚本字节码(一种可移植的中间代码),由引擎的脚本虚拟机在运行时执行

; ExecuteUbergraph_Missile
    EX_ComputedJump 4E
   EX_LocalVariable 00 C0 51 A3 FA 6A 01 00 00 ; ReceiveTick entry
      EX_Tracepoint 5E
  EX_WireTracepoint 5A
             EX_Let 0F 60 52 A3 FA 6A 01 00 00 ; OffsetForward
   EX_LocalVariable 00 60 52 A3 FA 6A 01 00 00 ; OffsetForward
        EX_CallMath 68 00 57 00 D0 6A 01 00 00 ; UKismetMathLibrary::Multiply_FloatFloat
   EX_LocalVariable 00 80 64 A3 FA 6A 01 00 00 ; - A: DeltaSeconds
EX_InstanceVariable 01 C0 5B A3 FA 6A 01 00 00 ; - B: MovementSpeed
EX_EndFunctionParms 16
             EX_Let 0F E0 63 A3 FA 6A 01 00 00 ; Offset
   EX_LocalVariable 00 E0 63 A3 FA 6A 01 00 00 ; Offset
        EX_CallMath 68 00 D8 02 D0 6A 01 00 00 ; UKismetMathLibrary::MakeVector
   EX_LocalVariable 00 60 52 A3 FA 6A 01 00 00 ; - X: OffsetForward
      EX_FloatConst 1E 00 00 00 00             ; - Y: 0.0
      EX_FloatConst 1E 00 00 00 00             ; - Z: 0.0
EX_EndFunctionParms 16
      EX_Tracepoint 5E
   EX_FinalFunction 1C 00 72 03 CE 6A 01 00 00 ; AActor::K2_AddActorLocalOffset
   EX_LocalVariable 00 E0 63 A3 FA 6A 01 00 00 ; - DeltaLocation: Offset
           EX_False 28                         ; - bSweep: false
   EX_LocalVariable 00 20 65 A3 FA 6A 01 00 00 ; - [out] HitResult
           EX_False 28                         ; - bTeleport: false
EX_EndFunctionParms 16
  EX_WireTracepoint 5A
          EX_Return 04

编译器会对我们编写的C++代码进行一些优化,从而对性能更加友好。但是蓝图编译成的机器码则不会,因此蓝图相对C++来说,消耗更大。因此对于一些复杂的数学计算以及需要被频繁调用的函数(Tick),那么最好用C++实现

性能:结论和分析

上文关于性能方面,我们能得出什么结论?

如果有两个等效的函数,一个用 C++ 编写另一个用蓝图编写,那么 C++ 函数会更快;C++ 函数可以在 CPU 级别进行全面优化,并且不会产生任何脚本执行开销

顺便说一下,引擎的 Blueprint Nativization 功能就是用来避免这种开销。如果启用了蓝图本地化,那么脚本编译器不会生成脚本字节码,而是会吐出 C++ 源代码,该源代码可以直接编译为机器代码;生成的代码不具备可读性,也无法编辑:

void AWeapon_C__pf2513711887::bpf__RunWeaponTrace__pf(FTransform bpp__MuzzleTransform__pf, float bpp__TraceDistance__pf)
{
    FVector bpfv__TraceEnd__pf(EForceInit::ForceInit);
    FVector bpfv__TraceStart__pf(EForceInit::ForceInit);
    FVector bpfv__CallFunc_MakeVector_ReturnValue__pf(EForceInit::ForceInit);
    FVector bpfv__CallFunc_BreakTransform_Location__pf(EForceInit::ForceInit);
    FRotator bpfv__CallFunc_BreakTransform_Rotation__pf(EForceInit::ForceInit);
    FVector bpfv__CallFunc_BreakTransform_Scale__pf(EForceInit::ForceInit);
    FVector bpfv__CallFunc_TransformLocation_ReturnValue__pf(EForceInit::ForceInit);
    FVector bpfv__CallFunc_GetDirectionUnitVector_ReturnValue__pf(EForceInit::ForceInit);
    TArray&lt;AActor*&gt; bpfv__Temp_object_Variable__pf{};
    FHitResult bpfv__CallFunc_LineTraceSingle_OutHit__pf{};
    bool bpfv__CallFunc_LineTraceSingle_ReturnValue__pf{};
    bool bpfv__CallFunc_BreakHitResult_bBlockingHit__pf{};
    bool bpfv__CallFunc_BreakHitResult_bInitialOverlap__pf{};
    float bpfv__CallFunc_BreakHitResult_Time__pf{};
    float bpfv__CallFunc_BreakHitResult_Distance__pf{};
    FVector bpfv__CallFunc_BreakHitResult_Location__pf(EForceInit::ForceInit);
    FVector bpfv__CallFunc_BreakHitResult_ImpactPoint__pf(EForceInit::ForceInit);
    FVector bpfv__CallFunc_BreakHitResult_Normal__pf(EForceInit::ForceInit);
    FVector bpfv__CallFunc_BreakHitResult_ImpactNormal__pf(EForceInit::ForceInit);
    UPhysicalMaterial* bpfv__CallFunc_BreakHitResult_PhysMat__pf{};
    AActor* bpfv__CallFunc_BreakHitResult_HitActor__pf{};
    UPrimitiveComponent* bpfv__CallFunc_BreakHitResult_HitComponent__pf{};
    FName bpfv__CallFunc_BreakHitResult_HitBoneName__pf{};
    int32 bpfv__CallFunc_BreakHitResult_HitItem__pf{};
    int32 bpfv__CallFunc_BreakHitResult_FaceIndex__pf{};
    FVector bpfv__CallFunc_BreakHitResult_TraceStart__pf(EForceInit::ForceInit);
    FVector bpfv__CallFunc_BreakHitResult_TraceEnd__pf(EForceInit::ForceInit);
    float bpfv__CallFunc_ApplyPointDamage_ReturnValue__pf{};
    bool bpfv__CallFunc_IsValid_ReturnValue__pf{};
    int32 __CurrentState = 1;
    do
    {
        switch( __CurrentState )
        {
        case 1:
            {
                UKismetMathLibrary::BreakTransform(bpp__MuzzleTransform__pf,
                    /*out*/ bpfv__CallFunc_BreakTransform_Location__pf,
                    /*out*/ bpfv__CallFunc_BreakTransform_Rotation__pf,
                    /*out*/ bpfv__CallFunc_BreakTransform_Scale__pf);
                bpfv__TraceStart__pf = bpfv__CallFunc_BreakTransform_Location__pf;
            }
        case 2:
            {
                bpfv__CallFunc_MakeVector_ReturnValue__pf = UKismetMathLibrary::MakeVector(
                    bpp__TraceDistance__pf, 0.000000, 0.000000);
                bpfv__CallFunc_TransformLocation_ReturnValue__pf = UKismetMathLibrary::TransformLocation(
                    bpp__MuzzleTransform__pf, bpfv__CallFunc_MakeVector_ReturnValue__pf);
                bpfv__TraceEnd__pf = bpfv__CallFunc_TransformLocation_ReturnValue__pf;
            }
        case 3:
            {
                bpfv__CallFunc_LineTraceSingle_ReturnValue__pf = UKismetSystemLibrary::LineTraceSingle(
                    this, bpfv__TraceStart__pf, bpfv__TraceEnd__pf, ETraceTypeQuery::TraceTypeQuery3,
                    false, bpfv__Temp_object_Variable__pf, EDrawDebugTrace::None,
                    /*out*/ bpfv__CallFunc_LineTraceSingle_OutHit__pf, true,
                    FLinearColor(1.000000,0.000000,0.000000,1.000000),
                    FLinearColor(0.000000,1.000000,0.000000,1.000000), 5.000000);
            }
        case 4:
            {
                if (!bpfv__CallFunc_LineTraceSingle_ReturnValue__pf)
                {
                    __CurrentState = -1;
                    break;
                }
            }
        case 5:
            {
                UGameplayStatics::BreakHitResult(bpfv__CallFunc_LineTraceSingle_OutHit__pf,
                    /*out*/ bpfv__CallFunc_BreakHitResult_bBlockingHit__pf,
                    /*out*/ bpfv__CallFunc_BreakHitResult_bInitialOverlap__pf,
                    /*out*/ bpfv__CallFunc_BreakHitResult_Time__pf,
                    /*out*/ bpfv__CallFunc_BreakHitResult_Distance__pf,
                    /*out*/ bpfv__CallFunc_BreakHitResult_Location__pf,
                    /*out*/ bpfv__CallFunc_BreakHitResult_ImpactPoint__pf,
                    /*out*/ bpfv__CallFunc_BreakHitResult_Normal__pf,
                    /*out*/ bpfv__CallFunc_BreakHitResult_ImpactNormal__pf,
                    /*out*/ bpfv__CallFunc_BreakHitResult_PhysMat__pf,
                    /*out*/ bpfv__CallFunc_BreakHitResult_HitActor__pf,
                    /*out*/ bpfv__CallFunc_BreakHitResult_HitComponent__pf,
                    /*out*/ bpfv__CallFunc_BreakHitResult_HitBoneName__pf,
                    /*out*/ bpfv__CallFunc_BreakHitResult_HitItem__pf,
                    /*out*/ bpfv__CallFunc_BreakHitResult_FaceIndex__pf,
                    /*out*/ bpfv__CallFunc_BreakHitResult_TraceStart__pf,
                    /*out*/ bpfv__CallFunc_BreakHitResult_TraceEnd__pf);
                bpfv__CallFunc_IsValid_ReturnValue__pf = UKismetSystemLibrary::IsValid(
                    bpfv__CallFunc_BreakHitResult_HitActor__pf);
                if (!bpfv__CallFunc_IsValid_ReturnValue__pf)
                {
                    __CurrentState = -1;
                    break;
                }
            }
        case 6:
            {
                bpfv__CallFunc_GetDirectionUnitVector_ReturnValue__pf = UKismetMathLibrary::GetDirectionUnitVector(
                    bpfv__TraceStart__pf, bpfv__TraceEnd__pf);
                UGameplayStatics::BreakHitResult(bpfv__CallFunc_LineTraceSingle_OutHit__pf,
                    /*out*/ bpfv__CallFunc_BreakHitResult_bBlockingHit__pf,
                    /*out*/ bpfv__CallFunc_BreakHitResult_bInitialOverlap__pf,
                    /*out*/ bpfv__CallFunc_BreakHitResult_Time__pf,
                    /*out*/ bpfv__CallFunc_BreakHitResult_Distance__pf,
                    /*out*/ bpfv__CallFunc_BreakHitResult_Location__pf,
                    /*out*/ bpfv__CallFunc_BreakHitResult_ImpactPoint__pf,
                    /*out*/ bpfv__CallFunc_BreakHitResult_Normal__pf,
                    /*out*/ bpfv__CallFunc_BreakHitResult_ImpactNormal__pf,
                    /*out*/ bpfv__CallFunc_BreakHitResult_PhysMat__pf,
                    /*out*/ bpfv__CallFunc_BreakHitResult_HitActor__pf,
                    /*out*/ bpfv__CallFunc_BreakHitResult_HitComponent__pf,
                    /*out*/ bpfv__CallFunc_BreakHitResult_HitBoneName__pf,
                    /*out*/ bpfv__CallFunc_BreakHitResult_HitItem__pf,
                    /*out*/ bpfv__CallFunc_BreakHitResult_FaceIndex__pf,
                    /*out*/ bpfv__CallFunc_BreakHitResult_TraceStart__pf,
                    /*out*/ bpfv__CallFunc_BreakHitResult_TraceEnd__pf);
                bpfv__CallFunc_ApplyPointDamage_ReturnValue__pf = UGameplayStatics::ApplyPointDamage(
                    bpfv__CallFunc_BreakHitResult_HitActor__pf, 1.000000,
                    bpfv__CallFunc_GetDirectionUnitVector_ReturnValue__pf,
                    bpfv__CallFunc_LineTraceSingle_OutHit__pf, bpv__OwningController__pf, this,
                    CastChecked<UClass>(
                        CastChecked<UDynamicClass>(AWeapon_C__pf2513711887::StaticClass())->UsedAssets[0],
                        ECastCheckedType::NullAllowed));
                __CurrentState = -1;
                break;
            }
        default:
            break;
        }
    } while( __CurrentState != -1 );
}

但最终产生的是相同的原生函数调用,无需运行在脚本虚拟机中

因此我们可以对蓝图节点中开销比较大的位置用C++重构,而这些位置可能包括各种底层系统,或是处理大量运算的循环遍历,以及涉及到处理大量Actor对象

如果一个在蓝图中实现的功能,你花了一周的时间用 C++ 重写整个功能,实现了20倍的性能提升,但其实用蓝图实现该功能所花费的时间也就 0.1ms ,那么你其实在做”无用功”。你应该使用分析器衡量性能,并根据具体数据来做判断

项目组织: 类的设计

游戏编程不仅仅是实现某个函数,虚幻是一个面向对象的引擎,因此通常将这些函数编写为类的一部分;在开始实现类的函数之前,首先需要定义类

/** Flies forward from where it's spawned, exploding on contact. */
UCLASS()
class AMissile : public AActor
{
    GENERATED_BODY()
    // Declare member variables and member functions here
};

定义一个类意味着确定它应该负责什么,然后弄清楚它需要哪些属性和函数,同时还将决定哪些属性和方法会对外开放。 C++的类定义写在头文件中:

/** Flies forward from where it's spawned, exploding on contact. */
UCLASS()
class AMissile : public AActor
{
    GENERATED_BODY()

public:
    /** Root collision sphere. */
    UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category="Components")
    class USphereComponent* SomeComponent;

public:
    /** How fast we should move forward, in centimeters per second. */
    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Missile")
    float MovementSpeed;

    /** If we fly this far without hitting anything, we'll explode. */
    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Missile")
    float SelfDestructDistance;

private:
    /** How far we've flown since spawning. */
    UPROPERTY(VisibleAnywhere, Category="Missile|State")
    float DistanceTraveled;

public:
    AMissile(const FObjectInitializer& ObjectInitializer);
    virtual void Tick(float DeltaSeconds) override;

private:
    void Explode(const FHitResult& Hit);
};

而函数的实现,一般写在对应的.cpp文件中

AMissile::AMissile(const FObjectInitializer& ObjectInitializer)
    : Super(ObjectInitializer)
{
    PrimaryActorTick.bCanEverTick = true;
    PrimaryActorTick.bStartWithTickEnabled = true;

    CollisionComponent = ObjectInitializer.CreateDefaultSubobject<USphereComponent>(this, TEXT("CollisionComponent"));
    CollisionComponent->SetCollisionProfileName(UCollisionProfile::BlockAllDynamic_ProfileName);
    RootComponent = CollisionComponent;

    MovementSpeed = 500.0f;
}

void AMissile::Tick(float DeltaSeconds)
{
    Super::Tick(DeltaSeconds);

    const float OffsetForward = MovementSpeed * DeltaSeconds;
    const FVector Offset(OffsetForward, 0.0f, 0.0f);
    DistanceTraveled += OffsetForward;

    FHitResult Hit;
    AddActorLocalOffset(Offset, true, &Hit);
    if (Hit.bBlockingHit || SelfDestructDistance > 0.0f && DistanceTraveled >= SelfDestructDistance)
    {
        Explode(Hit);
    }
}

void AMissile::Explode(const FHitResult& Hit)
{
    SetActorTickEnabled(false);
    SetLifeSpan(1.0f);
}

蓝图则大致相当于这两个文件(.h 和 .cpp)之和,蓝图的父类及其组件、属性和函数列表构成了它的类定义

事件图(和其他函数图)包含函数实现

在这个级别上,C++和蓝图定义并实现新的类或数据类型的方式大致相等,但是当谈到类之间的依赖关系时差异就出现了

设计理念: 类型和依赖

在C++或者编辑器中创建类、结构体、枚举,都是在定义一个新类型

一种类型需要知道其它某个类型的时候,就产生依赖关系;如果可以的话,依赖最好做到单向。例如:游戏中发射导弹的发射器,那就意味着一种单向依赖:发射器需要导弹的具体类型才能够生成导弹实例,但是导弹并不需要知道有关发射器的任何逻辑

假设现在有个新的需求:游戏内任一时刻,只允许一枚导弹存在。发射器想发射新的导弹,必须要等到旧的导弹销毁了才能发射新的导弹,该怎么做?我们可以让导弹调用发射器上的相关函数来让发射器知道可以再次发射导弹

但这会创建双向依赖关系。能否发射导弹这是发射器的事儿,而导弹该做的事儿就是找到目标并爆炸。因此,为了保持单向依赖,我们给导弹一个在爆炸时调用的委托(蓝图中,委托被称为事件调度器(Event Dispatcher))。导弹只需在爆炸的时候调用这个委托,无需关心调用之后会发生什么。而发射器则可以监听这个委托并绑定回调方法用于更新自己内部的状态

随着项目变得越来越大,管理这些依赖关系变得越来越重要,要确保代码不同部分之间的边界得到明确定义

项目组织: C++ 模块

C++ 中实现这种分离的一种方法是使用模块(modules)。通常有一个主游戏模块,其中包含核心游戏玩法类,例如 GameMode、PlayerController 和 Pawn等。随着项目变得越来越复杂,可能会将不同的功能和系统拆分为各自独立的模块

为了让一个模块中的类引用另一个模块中的类,两个模块之间需要存在一个显式依赖关系,被引用的类或函数需要作为模块公共API的一部分导出

由于模块依赖通常应该始终是严格的单向,这导致了一种分层架构:

在这个例子中,武器模块位于核心游戏模块下方,所以我们可以让我们的 Pawn 生成一个 Weapon,并且 Pawn 可以从 Weapon 类调用函数和访问数据,但是 Weapon 永远不应该知道 Pawn 的任何信息。我们加这样的限制,就形成了一种设计方式:武器模块不应该依赖于核心模块

如果我们尝试编写违反既定设计的代码,那么构建系统将不允许这样做:我们将收到一个链接器错误,表明我们正在尝试使用来自非显式依赖项的模块的代码

// Compile error on #include:
// (Module has not been added as a dependency)

    [1/4] Missile.cpp
    E:\Cobalt\Source\CobaltWeapons\Private\Missile.cpp(7):
      fatal error C1083:
        Cannot open include file: 'CobaltPlayerController.h':
          No such file or directory /* [in the include path for this module] */

// Linker error on use of class:
// (Module is a dependency, but class is not exported)

    [1/2] UE4Editor-CobaltWeapons.dll
    Missile.cpp.obj : error LNK2019:
      unresolved external symbol
        "private: static class UClass * __cdecl
         ACobaltPlayerController::GetPrivateStaticClass(void)"
           (?GetPrivateStaticClass@ACobaltPlayerController@@CAPEAVUClass@@XZ)
      referenced in function
        "private: void __cdecl
         AMissile::Explode(struct FHitResult const &amp;)"
           (?Explode@AMissile@@AEAAXABUFHitResult@@@Z)

这通常表明我们需要更仔细地考虑我们在做什么,要么更改我们的代码以更好地适应既定设计,要么就得重新评估这些设计限制是否真的合理?

模块如果使用得当,带来的好处有如下几点:

  • 使用模块可以控制构建时间
  • 在团队中可以更轻松地确定哪些团队成员对代码库的不同部分拥有所有权
  • 减轻了认知负担,在单个模块内工作,不需要考虑其他模块的内容只专注当前模块的功能设计

当然,模块化是把双刃剑。将代码分离到单独的模块最关键的好处就是:让你能够按照既有的设计模式来添加新类型或者依赖项。而最主要的缺点是:在你添加新的类型或依赖时,他会强制你去考虑你的设计是否合理

项目组织: C++/蓝图 依赖

蓝图中没有这样的模块概念,你可以认为项目里的蓝图是个依赖于所有C++模块的”特殊模块”。蓝图可以自由引用C++代码模块中声明的任何类型(只要它被标记为:BlueprintType)

蓝图也是资产,所以对蓝图的管理也相当于资产管理,项目的资产管理因项目和团队而异。值得指出的是,可以使用编辑器的引用查看器来获取蓝图之间依赖关系,这些信息非常有用

蓝图中并不存在模块化的概念,但是你得知道,C++ 和蓝图之间其实存在概念意义上的单向模块依赖关系:蓝图可以依赖C++类型,但C++类不会知道任何蓝图类型相关的信息(比如:无法直接调用蓝图内的方法,获取蓝图定义的属性)

设计示例: 蓝图 到 C++

假设我们一直在蓝图中工作,我们有一个自定义的 Pawn 类和 Weapon 类

我们需要在这两个类之间实现一种简单的单向交互:在触发BeginPlay事件时 Pawn 对象要生成一个Weapon对象,如果玩家按下开火按钮,Pawn对象就会调用Weapon的 Fire函数

我们已经设置好了武器蓝图,当执行Fire函数时,我们会执行一次射线检测,然后生成粒子特效。现在假设我们想开始将一些核心类重构为 C++

如果我们将 Pawn 类移到 C++ 中, Weapon 类保留在蓝图,那么我们必须面对这样一个事实,即在我们的 C++ 模块中,Weapon类尚不存在;由于虚幻的反射系统,我们依然可以生成一个Weapon对象,任何UObject类,无论在哪里定义,必定存在一个UClass对象与之对应,只要我们能够获得 Weapon 类的UClass引用,就能够生成它的Actor对象

C++ 重构的Pawn类如下:

#pragma once

#include "CoreMinimal.h"
#include "GameFramework/DefaultPawn.h"

#include "CobaltPawn.generated.h"

UCLASS()
class ACobaltPawn : public ADefaultPawn
{
    GENERATED_BODY()

public:
    UPROPERTY()
    TSubclassOf<AActor> WeaponClass;

    UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category="Cobalt")
    AActor* Weapon;

public:
    ACobaltPawn(const FObjectInitializer& ObjectInitializer);

protected:
    virtual void BeginPlay() override;
    virtual void SetupPlayerInputComponent(UInputComponent* PlayerInputComponent) override;

private:
    UFUNCTION() void OnFirePressed();
};

这里我们给了Pawn两个属性: WeaponClass是要生成的武器类的UClass引用 ,而Weapon则是用来保存生成的武器实例

#include "CobaltPawn.h"

#include "UObject/ConstructorHelpers.h"
#include "Components/InputComponent.h"

ACobaltPawn::ACobaltPawn(const FObjectInitializer&amp; ObjectInitializer)
    : Super(ObjectInitializer)
{
    static ConstructorHelpers::FClassFinder<AActor>WeaponClassFinder(TEXT("/Game/Core/Weapon"));
    WeaponClass = WeaponClassFinder.Class;
}

在构造函数中,我们根据路径直接获取武器蓝图类的 UClass 引用然用WeaponClass属性来保存它,当然这种根据路径获取的硬编码方式不太好,下文会给出替代方案。在 中BeginPlay我们可以生成该类的一个实例

void ACobaltPawn::BeginPlay()
{
    Super::BeginPlay();

    if (WeaponClass != nullptr)
    {
        FActorSpawnParameters SpawnInfo;
        SpawnInfo.Owner = this;
        SpawnInfo.Instigator = this;

        const FTransform SpawnOffset(FQuat::Identity, FVector(0.0f, 15.0f, -15.0f));
        const FTransform SpawnTransform = GetActorTransform() * SpawnOffset;
        Weapon = GetWorld()->SpawnActor<AActor>(WeaponClass, SpawnTransform, SpawnInfo);
    }
}

注意,Weapon属性的类型是 AActor*,因为Weapon类是在蓝图中定义的,所以C++实现的Pawn类获取不到Weapon类的任何信息(只知道它是Actor的子类)

void ACobaltPawn::SetupPlayerInputComponent(UInputComponent* PlayerInputComponent)
{
    Super::SetupPlayerInputComponent(PlayerInputComponent);

    PlayerInputComponent->BindAction(TEXT("Fire"), IE_Pressed, this, &ACobaltPawn::OnFirePressed);
}

void ACobaltPawn::OnFirePressed()
{
    if (Weapon)
    {
        // Manually call a Blueprint function from C++: this is dumb and you shouldn't do it
        UFunction* FireFunction = Weapon->FindFunction(TEXT("Fire"));
        if (FireFunction)
        {
            Weapon->ProcessEvent(FireFunction, nullptr);
        }
    }
}

这意味着我们不能调用Weapon类的Fire函数,技术上我们可以通过名称查找函数并动态调用它,但这种做法非常不推荐

好的解决方案是将Weapon类重构为C++类。但是Weapon类会生成粒子特效,而对于这些装饰性质的效果,我们更偏向于在蓝图中实现。最终在C++中定义Weapon基类,然后蓝图中继承它并进一步完善细节

在我们的 C++ Weapon 类中,我们实现底层相关功能:Fire 函数,以及它需要的其他数据或辅助函数等

#pragma once

#include "CoreMinimal.h"
#include "GameFramework/Actor.h"

#include "Weapon.generated.h"

UCLASS()
class AWeapon : public AActor
{
    GENERATED_BODY()

public:
    /** Placed at the end of the weapon, +X pointing out in the direction of fire. */
    UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category="Components")
    class USceneComponent* MuzzleComponent;

public:
    AWeapon(const FObjectInitializer& ObjectInitializer);
    void Fire();

private:
    void RunWeaponTrace(const FTransform& MuzzleTransform, float TraceDistance);
};

我们可以对Pawn进行一些更改:

public:
    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Cobalt")
    TSubclassOf&lt;class AWeapon&gt; WeaponClass;

    UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category="Cobalt")
    class AWeapon* Weapon;

在.cpp文件中, 我们不在需要直接引用蓝图,我们只需要将WeaponClass初始化为我们的Weapon基类

#include "Weapon.h"

ACobaltPawn::ACobaltPawn(const FObjectInitializer& ObjectInitializer)
    : Super(ObjectInitializer)
{
    WeaponClass = AWeapon::StaticClass();
}

生成的方法区别不大,只是我们可以更加精确定义它的类型为AWeapon

Weapon = GetWorld()->SpawnActor<AWeapon>(WeaponClass, SpawnTransform, SpawnInfo);

输入绑定方法中,我们可以正常调用Fire函数了

void ACobaltPawn::OnFirePressed()
{
    if (Weapon)
    {
        Weapon->Fire();
    }
}

设计示例: 用 C++ 实现所有功能

我们要坚信:”真正的”程序员是不屑于用蓝图的,万物皆可C++ ; 所以我们需要在 C++ 的 Weapon 类添加一个网格组件(武器肯定是有自己的样式的嘛),其次因为枪口会产生火花特效,因此我们还得添加一个粒子组件

public:
    /** Placed at the end of the weapon, +X pointing out in the direction of fire. */
    UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category="Components")
    class USceneComponent* MuzzleComponent;

    UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category="Components")
    class UStaticMeshComponent* MeshComponent;

public:
    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Weapon")
    class UParticleSystem* MuzzleFlashParticleSystem;

在构造函数中,需要获取对武器资产的引用。我们可以使用静态 FObjectFinder 来确保此资产查找仅在游戏首次启动时执行

AWeapon::AWeapon(const FObjectInitializer& ObjectInitializer)
    : Super(ObjectInitializer)
{
    static ConstructorHelpers::FObjectFinder<UStaticMesh> WeaponMeshFinder(
        TEXT("StaticMesh'/Game/Assets/Weapon/SM_Weapon.SM_Weapon'"));
    static ConstructorHelpers::FObjectFinder<UParticleSystem> MuzzleFlashParticleSystemFinder(
        TEXT("ParticleSystem'/Game/Assets/Weapon/PS_Weapon_MuzzleFlash.PS_Weapon_MuzzleFlash'"));

    RootComponent = ObjectInitializer.CreateDefaultSubobject<USceneComponent>(this, TEXT("RootComponent"));

    MuzzleComponent = ObjectInitializer.CreateDefaultSubobject<USceneComponent>(this, TEXT("MuzzleComponent"));
    MuzzleComponent->SetupAttachment(RootComponent);
    MuzzleComponent->SetRelativeLocation(FVector(48.0f, 0.0f, 0.0f));

    MeshComponent = ObjectInitializer.CreateDefaultSubobject<UStaticMeshComponent>(this, TEXT("MeshComponent"));
    MeshComponent->SetupAttachment(RootComponent);
    MeshComponent->SetStaticMesh(WeaponMeshFinder.Object);
    MeshComponent->SetCollisionProfileName(UCollisionProfile::NoCollision_ProfileName);
    MeshComponent->SetRelativeLocation(FVector(20.0f, 0.0f, 0.0f));

    MuzzleFlashParticleSystem = MuzzleFlashParticleSystemFinder.Object;
}

我们还将创建和配置一个UStaticMeshComponent,确保我们手动输入正确的偏移量以匹配我们之前在编辑器中配置的内容,然后我们可以使用和蓝图中调用相同的功能在武器开火时产生枪口闪光粒子效果

void AWeapon::Fire()
{
    const FTransform MuzzleTransform = MuzzleComponent->GetComponentTransform();
    RunWeaponTrace(MuzzleTransform, 5000.0f);

    if (MuzzleFlashParticleSystem)
    {
        UGameplayStatics::SpawnEmitterAttached(MuzzleFlashParticleSystem, MuzzleComponent);
    }
}

上面的代码会产生以下问题:

  • 资产路径硬编码到C++源代码中,一旦资产路径有改动,代码就失效
  • 游戏启动并注册了AWeapon 类,就会立即加载这些资产并且保留在内存中

而且,就我们组织项目的方式而言,这种做法也存在问题。要使用哪些资产的是非常高层的问题,而一般来说C++基础类则旨在处理更底层的功能。相对来说,在蓝图中处理资产,会获得更自然的用户体验(所见即所得),即时的视觉反馈方便编辑和微调各种属性,同时资产的路径更改引擎也会帮我们重定向(在编辑器内移动资产)

设计示例: C++ 实现基础,蓝图填充细节

纯C++实现缺点太多,那么让我们看看我们如何利用蓝图来处理这些装饰性细节

首先,让我们处理粒子特效部分。我不希望 C++ 类关心特定的视觉效果,我们只希望它确保这个特效能够生成。所以我们可以声明一个名为PlayFireEffects的函数(标记BlueprintImplementableEvent),而我们只需在C++中调用这个函数,剩下的显示效果交给蓝图实现

UFUNCTION(BlueprintImplementableEvent, Category="Weapon")
void PlayFireEffects();

接下来,让我们看看网格组件。如果我们的 C++ 代码需要控制网格——例如,在运行时打开或关闭碰撞——那么将这个组件声明为我们的 C++ 类的一部分是完全合理的,我们可以省略资产引用,让蓝图子类负责完全自定义网格组件。但在我们的示例中,网格纯粹是为了展示所以我们将把它完全排除在基类之外

新的 Weapon 基类头文件如下:

#pragma once
#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "Weapon.generated.h"

UCLASS()
class AWeapon : public AActor
{
    GENERATED_BODY()

public:
    UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category="Components")
    class USceneComponent* MuzzleComponent;

public:
    AWeapon(const FObjectInitializer& ObjectInitializer);
    void Fire();

    UFUNCTION(BlueprintImplementableEvent, Category="Weapon")
    void PlayFireEffects();

private:
    void RunWeaponTrace(const FTransform& MuzzleTransform, float TraceDistance);
};

对应的.cpp如下:

#include "Weapon.h"
#include "Components/SceneComponent.h"
#include "Engine/World.h"
#include "DamageType_WeaponFire.h"

static const ECollisionChannel ECC_WeaponFire = ECC_GameTraceChannel1;

AWeapon::AWeapon(const FObjectInitializer& ObjectInitializer): Super(ObjectInitializer)
{
    RootComponent = ObjectInitializer.CreateDefaultSubobject<USceneComponent>(this, TEXT("RootComponent"));
    MuzzleComponent = ObjectInitializer.CreateDefaultSubobject<USceneComponent>(this, TEXT("MuzzleComponent"));
    MuzzleComponent->SetupAttachment(RootComponent);
    MuzzleComponent->SetRelativeLocation(FVector(100.0f, 0.0f, 0.0f));
}

void AWeapon::Fire()
{
    const FTransform MuzzleTransform = MuzzleComponent->GetComponentTransform();
    RunWeaponTrace(MuzzleTransform, 5000.0f);
    PlayFireEffects();
}

void AWeapon::RunWeaponTrace(const FTransform& MuzzleTransform, float TraceDistance)
{
    const FVector TraceStart = MuzzleTransform.GetLocation();
    const FVector TraceEnd = TraceStart + (MuzzleTransform.GetUnitAxis(EAxis::X) * TraceDistance);
    const FCollisionQueryParams QueryParams(TEXT("WeaponTrace"), false, this);

    FHitResult Hit;
    if (GetWorld()->LineTraceSingleByChannel(Hit, TraceStart, TraceEnd, ECC_WeaponFire, QueryParams))
    {
        if (Hit.Actor.IsValid())
        {
            const float DamageAmount = 1.0f;
            const FVector ShotFromDirection = (TraceEnd - TraceStart).GetSafeNormal();
            const TSubclassOf<UDamageType> DamageTypeClass = UDamageType_WeaponFire::StaticClass();
            const FPointDamageEvent DamageEvent(DamageAmount, Hit, ShotFromDirection, DamageTypeClass);
            Hit.Actor->TakeDamage(DamageAmount, DamageEvent, GetInstigatorController(), this);
        }
    }
}

进入编辑器,打开我们原来的武器蓝图,删除其他所有内容,只留下粒子效果和网格组件,然后将它的父类改为我们C++中定义的Weapon类

我们所要做的就是将PlayFireEffects事件与我们的粒子特效绑定,这样我们的武器就算完成了。现在我们有了一个扩展我们的 C++ Weapon 类的蓝图,编译蓝图后,我们最终会得到一个从蓝图生成的新武器类

现在唯一的问题是:我们如何使用这个新的 UBlueprintGeneratedClass 而不是我们 C++ 中指定的Weapon基类。我们所要做的就是创建Pawn的蓝图子类,并更改该WeaponClass属性的默认值。然后我们可以使用这个新的 Pawn 类作为我们GameMode的默认Pawn类

通过这种方式,我们保持了一个简洁的设计,其中的依赖关系为单向:我们有一个更高级的蓝图层,它构建在较底的C++层之上,并且每一层都处理一组明确定义的职责,并确保层之间的最小耦合度

传统编程 / 脚本细分

这是一个非常简单的例子,但我希望它能说明原理。这种以结构化,互补的方式使用蓝图和C++开发模式,就是我之前提到的传统方法,在中间画了一条线

这是一种经典模型,对于中大型游戏开发团队来说,往往是最佳方法。正如我们已经讨论过的,虚幻引擎提供了足够的灵活性,所以即便你开发的是小型游戏或者缺乏C++开发经验,那么还有蓝图可以拯救你

设计示例: 蓝图函数库

如果实在觉得麻烦,也不一定必须将整个类重构为C++。如果你创建一个基于UBlueprintFunctionLibrary的类,你可以添加静态BlueprintCallable函数。所以我们也可以保留我们原来的Pawn和Weapon蓝图类,并在需要时将单个函数重构为C++(在蓝图函数库中)

我们将射线检测代码放入蓝图函数库中,示例如下  WeaponStatics.h:

#pragma once

#include "CoreMinimal.h"
#include "Kismet/BlueprintFunctionLibrary.h"

#include "WeaponStatics.generated.h"

UCLASS()
class UWeaponStatics : public UBlueprintFunctionLibrary
{
    GENERATED_BODY()

public:
    UFUNCTION(BlueprintCallable, Category="Cobalt|Weapon", meta=(WorldContext="WorldContextObject"))
    static bool RunWeaponTrace(
        UObject* WorldContextObject, const FTransform&amp; MuzzleTransform, float TraceDistance, FHitResult&amp; OutHit);
};

对应的 WeaponStatics .cpp如下:

#include "WeaponStatics.h"

#include "Engine/Engine.h"
#include "Engine/World.h"
#include "GameFramework/Actor.h"

#include "DamageType_WeaponFire.h"

static const ECollisionChannel ECC_WeaponFire = ECC_GameTraceChannel1;

bool UWeaponStatics::RunWeaponTrace(UObject* WorldContextObject, const FTransform& MuzzleTransform, float TraceDistance, FHitResult& OutHit)
{
    UWorld* World = GEngine->GetWorldFromContextObject(WorldContextObject, EGetWorldErrorMode::LogAndReturnNull);
    AActor* Actor = Cast<AActor>(WorldContextObject);
    if (World && Actor)
    {
        const FVector TraceStart = MuzzleTransform.GetLocation();
        const FVector TraceEnd = TraceStart + (MuzzleTransform.GetUnitAxis(EAxis::X) * TraceDistance);
        const FCollisionQueryParams QueryParams(TEXT("WeaponTrace"), false, Actor);

        if (World->LineTraceSingleByChannel(OutHit, TraceStart, TraceEnd, ECC_WeaponFire, QueryParams))
        {
            if (OutHit.Actor.IsValid())
            {
                const float DamageAmount = 1.0f;
                const FVector ShotFromDirection = (TraceEnd - TraceStart).GetSafeNormal();
                const TSubclassOf<UDamageType> DamageTypeClass = UDamageType_WeaponFire::StaticClass();
                const FPointDamageEvent DamageEvent(DamageAmount, OutHit, ShotFromDirection, DamageTypeClass);
                OutHit.Actor->TakeDamage(DamageAmount, DamageEvent, Actor->GetInstigatorController(), Actor);
            }
            return true;
        }
    }
    return false;
}

蓝图调用方式如下:

通过以上示例我们了解了:引擎底层在编译或者运行时发生了什么,实际项目通常是如何组织并保持其简洁与可维护的设计模式,以及C++和蓝图对性能以及项目组织形式的影响

既然我们已经讲完了那些比较底层的方面,那么就通过查看C++和蓝图之间更加直接明显的区别来结束这个话题吧。C++和蓝图各自都有比对方更有优势的点

蓝图优势: 资产, 可视, 脚本事件

蓝图更适合处理资产和视觉效果。C++ 代码中只能盲目推测运行时将出现哪些资产;而蓝图本身就是资产,编辑蓝图时,你可以浏览所有的资产并在蓝图中使用他们而且所见即所得,而且方便调整各种参数

而在 C++ 代码直接引用资产时,会在编译的游戏模块和资产之间创建依赖关系。如果资产发生变化,需手动更新源代码;而蓝图则不用担心,引擎会自动处理

在流程控制方面,蓝图也有明显优势。如果你使用的是事件而不是函数,则可以充分利用事件和回调函数从而非常直观的方式编写异步代码。我们看下面一个例子:

如果我想让一个角色移动到某个位置A,然后等待 3 秒,接着每半秒检查一次门是否打开,如果打开了就射击敌人直到敌人死亡,之后再穿过这个门到下一个位置B

在蓝图中,可以很快速的实现,并且整个过程一目了然非常清晰易懂。在 C++ 中也可以做同样的事情,实现如下:

void ATestSequence::Start()
{
    AAIController* Controller = Character ? Character->GetController<AAIController>() : nullptr;
    if (Controller && PointA)
    {
        Controller->ReceiveMoveCompleted.AddDynamic(this, &ATestSequence::OnFinishedMove);
        if (Controller->MoveToActor(PointA, 5.0f) == EPathFollowingRequestResult::RequestSuccessful)
        {
            MoveToPointARequestID = Controller->GetCurrentMoveRequestID();
        }
    }
}

void ATestSequence::OnFinishedMove(FAIRequestID RequestID, EPathFollowingResult::Type Result)
{
    if (RequestID == MoveToPointARequestID)
    {
        GetWorldTimerManager().SetTimer(CheckDoorTimer, this, &ATestSequence::CheckDoor, 3.0f);
    }
}

void ATestSequence::CheckDoor()
{
    if (Door && Door->IsOpen())
    {
        if (Character && Enemy)
        {
            Enemy->Died.AddUObject(this, &ATestSequence::OnEnemyDied);
            Character->SetAttackTarget(Enemy);
        }
    }
    else
    {
        GetWorldTimerManager().SetTimer(CheckDoorTimer, this, &ATestSequence::CheckDoor, 0.5f);
    }
}

void ATestSequence::OnEnemyDied()
{
    AAIController* Controller = Character ? Character->GetController<AAIController>() : nullptr;
    if (Controller && PointB)
    {
        Controller->MoveToActor(PointB, 5.0f);
    }
}

虽然它的工作原理是一样的,但它的表现力要差得多,更难调整和迭代。像这样的脚本事件通常使用Sequencer来实现,它允许您使用事件轨道来轻松整合关卡蓝图以及Actor的蓝图函数

事件图还提供了时间轴,这一直是一种非常方便的制作时间驱动的动画效果的工具。C++中也可以完成类似的事情,但是这通常涉及到一些曲线资产,当然你也可以在代码中用方程组实现那些曲线,但是这很蛋疼

蓝图优势: 简单好用

蓝图允许你非常快速的进行测试和迭代,整个蓝图开发都是在编辑器环境,还可以直接在编辑器中播放(PIE),同时还可以浏览事件图标检查属性值以及对脚本进行调试

相对C++来说,蓝图的用户群体范围更大一些,对于C++小白来说,使用蓝图是一个很好的起点。可能你已经是个C++大佬,但不代表每个人都是。当策划,美术,程序,QA 都可以很安全轻松的用蓝图为项目做贡献的时候,项目肯定会变得更好,众人拾柴火焰高嘛

无论哪种方式,高质量的代码都需要慢慢的积累提升——有人写出漏洞百出的C++代码,也会有人连出设计精妙且易懂的蓝图。但是想对于鬼画符的糟糕蓝图,存在漏洞的C++代码往往对项目更为致命,因为蓝图通常不会导致崩溃

蓝图具有可探索性,所有你能使用的类型和函数都整合在蓝图编辑器中,你几乎可以在无需阅读文档的情况下,就能够探索并了解这些蓝图功能

如果你刚开始使用虚幻引擎,即便你有 C++开发经验,先从蓝图入手也是不错的主意,反正你使用蓝图学到的东西都可以直接转移到C++

那么,C++又有哪些优势呢?

C++ 优势: 性能

最大的优势就是运行时的性能。C++代码会在编译时针对目标运行平台进行充分优化。在构建Shipping版本时,项目源代码会被编译为机器码,高度原生话且没有多余的开销

尽管蓝图原生化在某些情况下会得到大的性能提升,但它却为项目构建过程增加了一些相当不稳定的复杂性

C++ 优势: 引擎功能

引擎在C++中公开了更多的功能,哪些底层功能往往又非常有用。你可以充分利用日志记录系统,通过输出日志来查看代码功能或者查找问题

// Log.h
#include "Logging/LogMacros.h"

// Log.cpp
#include "Log.h"
DEFINE_LOG_CATEGORY(LogCobaltCore);

// DefaultEngine.ini
[Core.Log]
LogCobaltCore=VeryVerbose

// Log a critical error message and halt execution
UE_LOG(LogCobaltCore, Fatal, TEXT("Oh no!"));

// Log an error (red) or warning (yellow), with printf-style formatting
UE_LOG(LogCobaltCore, Error, TEXT("Error: %d"), SomeIntValue);
UE_LOG(LogCobaltCore, Warning, TEXT("Warning: '%s'"), *SomeStringValue);

// Log normal messages which may or many not be shown depending on the verbosity level
UE_LOG(LogCobaltCore, Display, TEXT("Something any developer should see"));
UE_LOG(LogCobaltCore, Log, TEXT("Feedback about routine operation"));
UE_LOG(LogCobaltCore, Verbose, TEXT("Diagnostic info to aid debugging"));
UE_LOG(LogCobaltCore, VeryVerbose, TEXT("Spammy diagnostic info"));

你可以添加断言来保持系统稳定性,确保你的代码在预期的情况下执行,并在出现问题时提供有用的错误消息

还可以自定义控制台命令,让你在运行时通过控制台命令在运行时实时控制游戏的行为:

// At the top of a .cpp file:
static TAutoConsoleVariable&lt;float&gt; CVarControllerInterpSpeed(
    TEXT("CobaltCore.Controller.InterpSpeed"),
    8.0f,
    TEXT("Speed for smoothing out controller transforms,\n")
    TEXT(" or 0 to disable interpolation entirely")
);

// Within function bodies in the same .cpp file:
const float InterpSpeed = CVarControllerInterpSpeed.GetValueOnGameThread();

// At runtime, open the console with (~) and run:
// - `CobaltCore.Controller.InterpSpeed` to get the current value
// - `CobaltCore.Controller.InterpSpeed [new-value]` to update the value

还可以添加自定义统计类别来捕获详细的分析信息,来衡量游戏中每个系统和功能对性能的影响

还可以更好地控制你的类型和接口的公开方式、C++ 代码库的其他部分、蓝图脚本以及编辑器中的用户:

class FSomeClass
{
public:
  // Accessible to all other code

protected:
  // Accessible to subclasses

private:
  // Internal to this class alone
};

// Not exposed to Blueprints at all:
   UCLASS(NotBlueprintType, NotBlueprintable)
// Can be referenced but not extended:
   UCLASS(BlueprintType, NotBlueprintable)
// Can be extended in Blueprints (default for AActor):
   UCLASS(BlueprintType, Blueprintable)

// Read-only to both users and Blueprints:
   UPROPERTY(VisibleAnywhere, BlueprintReadOnly)
// Can't be modified per-instance, but a new default value can be set per-Blueprint:
   UPROPERTY(EditDefaultsOnly, BlueprintReadWrite)
// Can be modified in the Details panel and by Blueprints:
   UPROPERTY(EditAnywhere, BlueprintReadWrite)

还可以更精确的控制网络赋值,使用优先级和相关性的自定义规则,你还可以利用复制图表系统等高级功能

还可以在底层建立原始的TCP和UDP套接字发送和接受数据(虽然UE本身就提供了网络),你也可以使用Http和Json模块与Web API进行通讯

还可以自定义序列化规则用来规定数据结构和类型写入到磁盘或者压缩进行网络复制

/** Example struct with custom serialization */
USTRUCT(BlueprintType)
struct FBoardCell
{
    GENERATED_BODY()

    UPROPERTY(EditAnywhere, BlueprintReadOnly)
    float Height;

    UPROPERTY(EditAnywhere, BlueprintReadOnly)
    int32 Flags;

    UPROPERTY(EditAnywhere, BlueprintReadOnly)
    int32 PlaneIndex;

    bool Serialize(FArchive& Ar);
};

template<>
struct TStructOpsTypeTraits<FBoardCell> : public TStructOpsTypeTraitsBase2<FBoardCell>
{
    enum
    {
        WithSerializer = true,
    }
};

bool FBoardCell::Serialize(FArchive& Ar)
{
    Ar.UsingCustomVersion(FBoardCustomVersion::GUID);

    if (Ar.IsLoading() || Ar.IsSaving())
    {
        const int32 BoardVer = Ar.CustomVer(FBoardCustomVersion::GUID);
        if (BoardVer < FBoardCustomVersion::SerializeRawCellValues)
        {
            UScriptStruct* Struct = FBoardCell::StaticClass();
            Struct->SerializeTaggedProperties(Ar, (uint8*)this, Struct, nullptr);
        }
        else
        {
            Ar << Height;
            if (BoardVer < FBoardCustomVersion::StoreCellTransformInPlane)
            {
                FVector_NetQuantize Normal = FVector::ZeroVector;
                Ar << Normal;
            }
            Ar << Flags;
            Ar << PlaneIndex;
        }
    }

    return true;
}

可以绑定保存和加载期间发生的底层事件,从而可以在加载时操作数据并提升向后的兼容性。可以添加特定于编辑器或者烘焙过程的代码和数据,而这些代码和数据在非编辑器构建过程中不会参与编译

UCLASS()
class ASomeActor : public AActor
{
    GENERATED_BODY()

public:
#if WITH_EDITORONLY_DATA
    UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category="Components")
    class UArrowComponent* ArrowComponent;
#endif

public:
    ASomeActor(const FObjectInitializer& ObjectInitializer);
    virtual void OnConstruction(const FTransform& Transform) override;
};

ASomeActor::ASomeActor(const FObjectInitializer& ObjectInitializer)
    : Super(ObjectInitializer)
{
    RootComponent = ObjectInitializer.CreateDefaultSubobject<USceneComponent>(this, TEXT("Root"));

#if WITH_EDITORONLY_DATA
    ArrowComponent = ObjectInitializer.CreateEditorOnlyDefaultSubobject<UArrowComponent>(this, TEXT("Arrow"));
    if (ArrowComponent)
    {
        ArrowComponent->SetupAttachment(RootComponent);
    }
#endif
}

void ASomeActor::OnConstruction(const FTransform& Transform)
{
    Super::OnConstruction(Transform);

#if WITH_EDITORONLY_DATA
    if (ArrowComponent)
    {
        ArrowComponent->SetRelativeTransform(FTransform::Identity);
    }
#endif
}

您可以添加编辑器模块,创建自定义界面布局、资产编辑器窗口和导入器以及新的编辑器模式和视口工具扩展编辑器

还可以绑定各种引擎和编辑器委托,从而在不同事件发生时运行自定义代码

C++ 优势: 链接库

C++模块中,无论是作为项目的一部分,还是在插件中,你都可以导入第三方插件库。 如果想将 C 或 C++ 库集成到我们的项目中,你可以针对你支持的平台把它构建成为静态或者共享库,然后更新你的模块Bulid.cs文件来把它链接进来,你就可在项目中是该该代码了

using System.IO;
using UnrealBuildTool;

public class MyModule : ModuleRules
{
    public MyModule(ReadOnlyTargetRules Target) : base(Target)
    {
        PCHUsage = PCHUsageMode.UseExplicitOrSharedPCHs;
        bEnforceIWYU = true;
        PublicDependencyModuleNames.AddRange(new string[] { "Core", "CoreUObject", "Engine"});

        // Let's say we have a library in MyModule/ThirdParty/somelib:
        // - somelib/include/somelib.h defines library functions
        // - somelib/lib/x64/somelib.lib has been built for our target platform
        // (This example assumes a single supported platform)
        string ModuleThirdPartyDir = Path.Combine(ModuleDirectory, "ThirdParty");
        string LibraryIncludeDir = Path.Combine(ModuleThirdPartyDir, "somelib/include");
        string LibraryStaticLibPath = Path.Combine(ModuleThirdPartyDir, "somelib/lib/x64/somelib.lib");

        // Code in MyModule can now #include "somelib.h" and call functions
        // whose implementations are compiled as part of somelib.lib
        PublicIncludePaths.Add(LibraryIncludeDir);
        PublicAdditionalLibraries.Add(LibraryStaticLibPath);
    }
}

这是C++最明显的优势之一,也是最强大的优势。理论上来说,你可以在你的游戏里面插入一个Windows系统(手动狗头)

C++优势: 比较和合并

从工作流的角度来看,C++与蓝图不同。C++代码非常容易进行差异比较以及合并。对于较小的项目,这可能不是一个大问题,但在较大的团队中,这就非常重要了。

向项目的版本控制系统(svn,git等)提交代码之前,一般都会比较修改过的文件,用于查看本次修改的内容。或者查看历史版本来定位bug。在这些情况下,你希望能够快速查看某个文件在每个提交版本的修改内容。C++代码是纯文本,比较差异则是一种基本功能,有很多工具可以逐行列出一个文本文件在两个不同版本之间的差异。合并是纯文本代码的另一个主要优势,两个人可以同时处理同一个源文件,版本控制系统可以自动将它们的修改合并在一起

但蓝图是二进制文件,而且为了查看、编辑它们必须打开项目编辑器;对蓝图进行比较差异或合并非常困难。幸运的是,编辑器包含一个用于比较蓝图的内置工具,针对一些简单情况效果还不错。

但要是想针对当前版本已经不存在的旧版本蓝图进行比较差异,那你可能会遇到问题

对蓝图进行代码审查在技术上是可行的,但相对检查文本代码修改这个处理过程要麻烦得多

蓝图并不是真的可以合并——虽然有一个内置的合并工具,当你需要解决蓝图修改冲突时,这个工具会派上用场,但是任何对于蓝图资产的合并,始终需要人工干预,即使是没有冲突的修改。合并工具相当有限,它基本上只向你显示相关修改,让你选择其中一个接受的版本——除此之外,你还需要手动修复。

所以传统观点是,你应该像对待任何其他资产一样对待蓝图,对于一个蓝图文件,同一时间只能由一个人进行编辑和提交

但最终这些都是可以接受的代价,考虑到蓝图的强大和有用之处,我不认为就因为蓝图不好合并就不使用

个人偏好

萝卜青菜各有所爱,我们都有个人偏好。但是必须保持一定的自我意识,以确保我们不会因为个人偏好影响判断

游戏开发很复杂,需要团队合作。当做出影响整个项目和整个团队的决策时(例如如何平衡 C++ 和蓝图),必须权衡许多更重要的因素:

  • 哪种方式对性能最好?
  • 哪种方式最适合项目的整体设计?
  • 哪种方式维护性更强?
  • 考虑到上线日期和预算限制,用哪种方式能更快速完成工作?

也许你早已有答案, “我不喜欢记C++语法” 或者 “我不喜欢连连看” 都属于个人偏好。如果基于上面的问题两中方式都可以,或者你只是一个游戏开发爱好者,那么你就尽管按照自己喜欢的方式来吧,毕竟乐于其中往往效果更好。我个人觉得C++和蓝图都应该去尝试一下,如果你花些时间了解他们各自的优势和劣势所在,我想你会发现它们都很有趣

总结

无论你当前负责项目哪个模块,我都鼓励你尝试C++和蓝图组合使用;感谢阅读,我希望你学到了一些新东西

[译文]5分钟系列—快速理解Floating-point Numbers(浮点数)

原文链接:What Are Floating-point Numbers?

前言

浮点数是一种以二进制形式存储数字的方法, 它允许我们使用固定数量的存储空间来表示”大范围的值”

为什么要了解浮点数?

了解了浮点数,以下两个问题你就能马上明白

  • 为什么 0.1 + 0.2 在某些情况下并不等于 0.3
  • 如何用二进制的形式存储小数( non-integer)

5分钟带你了解浮点数

首先,我们来看看二进制是怎么存储整数的。从低到高(右到左)每一位都表示 2 的幂 , 通过按权展开求和,我们能得到想要的整数,示例如下:

二进制表示整型

二进制表示整数,完美。但是怎么用二进制表示小数呢?比如:2.5

聪明的你肯定想到了,把二进制位分为两部分。左边部分表示小数点前面的数字(示例中的2),右边的部分表示小数点后面的数字(示例中的0.5),于是上图变为如下:

二进制表示非整型

看似解决,但仍然还是有很多小数没法表示,比如: 2.36 就没法表示,最接近的小数是2.375(用计算机术语就是:缺失精度),甚至我们都无法用它表示16.0( 因为左边只有四位,最大值只能表示15)

我们确实可以通过扩展位数用来增加所表示的小数的“大小”和“精度”,但是这样做不够灵活。有时候我们想存储值很大的小数,我们就希望左侧有更多位数;有时候我们想存储尾数(小数点后的数字)很大的小数,就肯定希望右边有更多的位数。那有没有一种方法,能动态满足我们的需求呢?它就是浮点数

浮点数的国际标准是:IEEE 754 ,它定义了32位和64位浮点值;我们用32位浮点值来举例,它的二进制结构如下(从左到右):

  • 第一位代表”符号(Sign)”; 0 表示整数,1表示负数
  • 接下来的8位代表”指数(Exponent)”
  • 最后的23位代表”尾数(Mantissa)”

我们在一个公式中,用到这三个值,公式计算的结果就是实际表示的数字:

你不用理解这个公式的具体运作方式,只要知道这种方式可以让我们更灵活更自由的用二进制表达小数。这也是为什么称之为:”浮点”, 它不像我们上面的示例,小数点在中间,相反它能通过调节指数从而移动小数点的位置,是不是很神奇?

舍入误差

有些分数,其实我们也无法用小数来准确表示。比如: ⅓ = 0.33333333333333…

同理,有些小数二进制也没法精确表示。比如:让我们尝试用二进制表示 0.1,大概如下:

我们可以通过减小指数让它无限接近0.1,但始终没法精确到刚好是0.1。这也是为什么你用chrome浏览器(按F12),在Console 输入: 0.1+0.2 回车,得出的结果是: 0.30000000000000004

因此但是考虑到可能会有这种结果,我们在比较浮点数是否相等的时候,方式2 比较合理:

float result1, result2;
#define RoundingValue = 0.0001f
//方式1:这种写法非常不推荐
if(result1 == result2)
{
    //do some thing 
}

//方式2:推荐写法
if(Math.abs(result1 - result2) < RoundingValue )
{
    //do some thing
}

拓展链接:

[原创]UE—GamePlay启动流程

趁着国庆假期把 GamePlay 框架又梳理了一番, 遂尝试将梳理的脉络用文字表达出来, 借此加深理解, 倘若能为他人解惑那更是善莫大焉; 当前引擎版本: UnrealEngine4.25.3

游戏引擎的GamePlay框架伪代码基本如下:

int main()
{
    init();
    while(!exit_requested)
    {
        update(); //input,gamelogic,render...
    }
    exit();
}
//CodeSnippet.1

UE的GamePlay框架也是遵循这个伪代码展开; 我们打开UE的引擎源码, 从 GuardedMain() 函数开始 (为了便于理解流程,本文所有代码段都对源码进行一定的删改 )

//Launch.cpp 
int32 GuardedMain(const TCHAR* CmdLine)
{
    struct EngineLoopCleanupGuard 
    { 
       ~EngineLoopCleanupGuard() 
       {
          GEngineLoop.Exit(); 
       }
    } CleanupGuard;
 
    //run early Initialization,load engine modules
    GEngineLoop.PreInit(CmdLine);
 
    //Create and initizlize a UEngine, run late Initialization,start the game
    GEngineLoop.Init();
 
    //kick off rendering,tick the engine,update RHI
    while(!IsEngineExitRequested())
    {
	 GEngineLoop.Tick();
    }
} 
//CodeSnippet.2

CodeSnippet.2 和 CodeSnippet.1 的区别就是退出函数。 GuardedMain() 定义了一个局部变量 CleanupGuard,在函数结束以后系统会调用 ~EngineLoopCleanupGuard(), 从而退出游戏

GEngineLoop.PreInit() 函数的主要内容就是按照顺序加载游戏相关的引擎、项目和第三方插件等模块, 代码如下:

//LaunchEngineLoop.cpp
int32 FEngineLoop::PreInit(const TCHAR* CmdLine)
{
    LoadCoreModules(); //CoreUObject
    LoadPreInitModules(); //Engine,Renderer,AnimGraphRuntime,RenderAPI...
    LoadCustomModules(); //EarliestPossible,PostConfigInit,PostSplashScreen...
    LoadStartupCoreModules(); //Core,Networking,SlateCore,Slate,Overlay...
    ...
    return 0;
}
//CodeSnippet.3

加载完当前模块会进行模块注册等相关内容(CDO也是在此阶段生成), 预初始化执行完就开始初始化,CodeSnippet.2 的 GEngineLoop.Init() 就会被执行,代码如下:

//LaunchEngineLoop.cpp
int32 FEngineLoop::Init()
{
    FString GameEngineClassName;
    GConfig->GetString(TEXT("/Script/Engine.Engine"), TEXT("GameEngine"), GameEngineClassName, GEngineIni);
    UClass* EngineClass = StaticLoadClass( UGameEngine::StaticClass(), nullptr, *GameEngineClassName);
   
    //create UGameEngine
    GEngine = NewObject<UEngine>(GetTransientPackage(), EngineClass);
    check( GEngine );
   
    //create GameInstance; Client: create ViewpointClient and LocalPlayer
    GEngine->Init(this);
    UEngine::OnPostEngineInit.Broadcast();
    FCoreDelegates::OnPostEngineInit.Broadcast();
   
    //initialize any late-loaded modules(加载哪些设置在postEngineinit阶段才加载的模块)
    IProjectManager::Get().LoadModulesForProject(ELoadingPhase::PostEngineInit);
    IPluginManager::Get().LoadModulesForEnabledPlugins(ELoadingPhase::PostEngineInit));

    //start the game,typically this load the default map
    GEngine->Start();
    GIsRunning = true;
    FCoreDelegates::OnFEngineLoopInitComplete.Broadcast();
    return 0;
}
//CodeSnippet.4

本文分析的是非编辑器环境,因此 创建的 UEngine 就是 UGameEngine, 创建完就执行初始化 GEngine->Init(this); 代码如下:

//GameEngine.cpp
void UGameEngine::Init(IEngineLoop* InEngineLoop)
{
    //create GameInstance
    FSoftClassPath GameInstanceClassName = GetDefault<UGameMapsSettings>()->GameInstanceClass;
    UClass* GameInstanceClass = LoadObject<UClass>(NULL, *GameInstanceClassName.ToString()));
    GameInstance = NewObject<UGameInstance>(this, GameInstanceClass);
 
    //create FWorldContext and dummy World,Init Subsystem
    GameInstance->InitializeStandalone();
	
    //Create ans Initialize the viewport client
    if(GIsClient)
    {
        ViewportClient = NewObject<UGameViewportClient>(this, GameViewportClientClass);
        ViewportClient->Init(*GameInstance->GetWorldContext(), GameInstance);
        GameViewport = ViewportClient;
        GameInstance->GetWorldContext()->GameViewport = ViewportClient;
        CreateGameViewport(ViewportClient);

        //Create a ULocalPlayer and associate it with viewpoint client
        FString Error;
        ViewportClient->SetupInitialLocalPlayer(Error);
        UGameViewportClient::OnViewportCreated().Broadcast();
    }
}
//CodeSnippet.5

CodeSnippet.5 主要就是创建 UGameInstance 并初始化。 此时开始出现 Client 和 Server ( 本文特指 Dedicated Server,暂不考虑 Listen Server) 的区别

Server

CodeSnippet.5 执行完毕,Server 端拥有的对象: UGameEngine , UGameInstance , UWorldContext以及创建的一个临时世界 ; 接下来就是执行 CodeSnippet.4 内的 GEngine->Start() , 它最终会调用 UEngine::LoadMap() 代码如下:

//UnrealEngine.cpp
bool UEngine::LoadMap( FWorldContext& WorldContext, FURL URL, class UPendingNetGame* Pending, FString& Error )
{
    //Let any interested parties konw that the current world is about to be unloaded
    FCoreUObjectDelegates::PreLoadMap.Broadcast(URL.Map);
   
    //Clear Current World
    // Destory player-controlled Pawns and PlayerControllers
    //Route the EndPlay event to all actors
    //Clean up the world, destory all actors
   
    //Notify the GameInstance of the map change in case it wants to load assets
    WorldContext.OwningGameInstance->PreloadContentForURL(URL);
   
    //Load our persistent level's map package and get top-level UWorld
    //UWorld,ULevel,Actor 等在编辑器已经生成好,打包的时候被序列化到了.umap文件内, 加载完反序列化就能拿到所有内容
    //也包括WordSettings对象(以及给它设置的各种对象配置)
    UPackage*  WorldPackage = LoadPackage(nullptr, *URL.Map, LOAD_None));;
    UWorld*    NewWorld = UWorld::FindWorldInPackage(WorldPackage);
   
    //Give the world a reference to the GameInstance
    NewWorld->SetGameInstance(WorldContext.OwningGameInstance);
    GWorld = NewWorld;
   
    //just-load UWorld and get it ready for gameplay
    WorldContext.SetCurrentWorld(NewWorld);
    WorldContext.World()->WorldType = WorldContext.WorldType;
    WorldContext.World()->AddToRoot();
    WorldContext.World()->InitWorld(); //物理,寻路导航,AI以及声音系统进行设置
    WorldContext.World()->SetGameMode(URL); //让GameInstance在世界生成GameMode,Server-Only
   
    //Load any per-map packages and make sure "always aloaded" sub-levels are fully loaded
    LoadPackagesFully(WorldContext.World(), FULLYLOAD_Map, WorldContext.World()->PersistentLevel->GetOutermost()->GetName());
    WorldContext.World()->FlushLevelStreaming(EFlushLevelStreamingType::Visibility);

    //Register components, then initialize all actors and their components
    {
       FRegisterComponentContext Context(WorldContext.World());
       WorldContext.World()->InitializeActorsForPlay(URL, true, &Context);
       Context.Process();
    }

    //Spawn play actors(i.e. PlayerControllers) for all active local players
    for(auto It = WorldContext.OwningGameInstance->GetLocalPlayerIterator(); It; ++It)
    {
        FString Error2;
        (*It)->SpawnPlayActor(URL.ToString(1),Error2,WorldContext.World());
    }
   
    //如果是DS或者监听服,那么创建Socket监听客户端请求
    if (Pending == NULL && (!GIsClient || URL.HasOption(TEXT("Listen"))))
    {
       if (!WorldContext.World()->Listen(URL))
       {
          UE_LOG(LogNet, Error, TEXT("LoadMap: failed to Listen(%s)"), *URL.ToString());
       }
    }

    //Route the BeginPlay event to indicate that all actors are present and initialized
    WorldContext.World()->BeginPlay();

    //let listeners konw that the map change is done
    PostLoadMapCaller.Broadcast(WorldContext.World());

    double StopTime = FPlatformTime::Seconds();
    UE_LOG(LogLoad, Log, TEXT("Took %f seconds to LoadMap(%s)"), StopTime - StartTime, *URL.Map);
    FLoadTimeTracker::Get().DumpRawLoadTimes();
    WorldContext.OwningGameInstance->LoadComplete(StopTime - StartTime, *URL.Map);

    // Successfully started local level.
    return true;
} 
//CodeSnippet.6

根据URL加载对应的 URL.ump , 然后将 URL.ump 内的所有对象反序列化出来。因此我们就有了新的UWorld,ULevel 以及场景内包含的 Actors

接下来执行的代码是 SetGameMode(URL) 它会创建一个很重要的对象: GameModeBase。它代表着游戏的规则,比如:游戏时长,玩家数量,输赢判定等

再接下来执行的代码是 InitializeActorsForPlay(URL, true, &Context) 它的作用是初始化当前关卡内所有Actors; 包括注册 Actors 的组件,执行 Actors 的三个初始化方法 PreInitializeComponents,InitializeComponents,PostInitializeComponents 等

GameModeBase 继承自 Actor , 因此它的 PreInitializeComponents 方法也会被调用, 代码如下:

//GameModeBase.cpp
void AGameModeBase::PreInitializeComponents()
{
    Super::PreInitializeComponents();
   
    //Spawn a GameState actor
    UWorld* World = GetWorld();
    FActorSpawnParameters SpawnInfo;
    SpawnInfo.Instigator = GetInstigator();
    SpawnInfo.ObjectFlags |= RF_Transient; // We never want to save game states or network managers into a map                            
    GameState = World->SpawnActor<AGameStateBase>(GameStateClass, SpawnInfo);
   
    //Associate the GameState with the world
    World->SetGameState(GameState);
    GameState->AuthorityGameMode = this;

    //spawn a GameNetWorkManager, Server Only
    AWorldSettings* WorldSettings = World->GetWorldSettings();
    World->NetworkManager = World->SpawnActor<AGameNetworkManager>(WorldSettings->GameNetworkManagerClass, SpawnInfo);
   
    //Initialize the GameState based upon the initial configuration of the GameMode
    InitGameState();
} 
//CodeSnippet.7

CodeSnippet.7 主要是生成 GameState , NetworkManager ; GameState 用来存储当前游戏模式的基础数据

Actor 在调用 InitializeComponents 方法时,如果Actor勾选了bAutoActivate ,则会执行激活Actor 。所以如果你想在Actor的 BeginPlay 之前做一些操作的话,最好的时机就是 PostInitializeComponents 方法内

接下来执行的代码是 Listen(URL), 启动 Server 的网络监听; 再就是调用所有Actors 的 BeginPlay

至此 Server 分析完毕,它目前具备了一个游乐场所需要的一切设备,梦幻的场景( UWorld,ULevel ), 有趣的游戏规则(UGameMode), 游乐场看门老头( Listen(URL) ) ,满心欢喜的在等待着 Client 加入这场游戏盛宴

Client

CodeSnippet.4 内的 GEngine->Init(this) 方法( CodeSnippet.5 ), 在Client端它会生成 GameViewportClient(可以想象成屏幕本身:它本质上就是作为渲染、声音以及输入系统的高级接口,也是用户与引擎之间的交互接口) 和 LocalPlayer(可以想象成坐在屏幕前的用户)

而CodeSnippet.4 内的 GEngine->Start()方法在 Client 它会调用到 UEngine::Browse() , 代码如下:

//UnrealEngine.cpp
EBrowseReturnVal::Type UEngine::Browse( FWorldContext& WorldContext, FURL URL, FString& Error )
{
    if( URL.IsLocalInternal() )
    {
       //Server: Create GameMode,GameSession,GameState,GameNetWorkManager
       return LoadMap( WorldContext, URL, NULL, Error ) ? EBrowseReturnVal::Success : EBrowseReturnVal::Failure;
    }
   
    if( URL.IsInternal() && GIsClient )
    {
       //Client, Create UPendingNetGame,UNetDriver,send "MIT_Hello" to Server
       WorldContext.PendingNetGame = NewObject<UPendingNetGame>();
       WorldContext.PendingNetGame->Initialize(URL);
       WorldContext.PendingNetGame->InitNetDriver();
       return EBrowseReturnVal::Pending;
    }
 }
//CodeSnippet.8

CodeSnippet.8 的主要功能是创建 UpendingNetGame 并初始化执行登录流程,流程大概如下:

UE4 登录流程
登录ds服务器流程

Create Pawn In Server

那么Server 和 Client , 分别是什么时候开始生成玩家的呢?

Server 是在收到 NMT_Join 消息, UWorld::SpawnPlayActor 生成 PlayerController, 代码如下:

APlayerController* UWorld::SpawnPlayActor(UPlayer* NewPlayer, ENetRole RemoteRole, const FURL& InURL, const FUniqueNetIdRepl& UniqueId, FString& Error, uint8 InNetPlayerIndex)
{
    AGameModeBase* const GameMode = GetAuthGameMode()

    //Give the GameMode a chance to accept the login
    APlayerController* const NewPlayerController = GameMode->Login(NewPlayer, RemoteRole, *InURL.Portal, Options, UniqueId, Error);

    //Possess the newly-spawned player
    NewPlayerController->NetPlayerIndex = InNetPlayerIndex;
    NewPlayerController->SetRole(ROLE_Authority);
    NewPlayerController->SetReplicates(RemoteRole != ROLE_None);
    NewPlayerController->SetPlayer(NewPlayer); //NewPlayer type is UNetConnection

    //Spawn Pawn
    GameMode->PostLogin(NewPlayerController);
    return NewPlayerController;
}
//CodeSnippet.9

在 NewPlayerController 的构造方法执行之后 PostActorConstruction方法会被调用,它内部会依次调用 PreInitializeComponents,InitializeComponents,PostInitializeComponents。 与 PlayerController 对应的 PlayerState 则会在 PostInitializeComponents 方法内生成并执行初始化

//PlayerController.cpp
void APlayerController::PostInitializeComponents()
{
    if ( !IsPendingKill() && (GetNetMode() != NM_Client) )
    {
       // create a new player replication info
       FActorSpawnParameters SpawnInfo;
       SpawnInfo.Owner = this;
       SpawnInfo.Instigator = GetInstigator();
       SpawnInfo.ObjectFlags |= RF_Transient; 

       const AGameModeBase* GameMode = GetWorld()->GetAuthGameMode();
       PlayerState = World->SpawnActor<APlayerState>(GameMode->PlayerStateClass, SpawnInfo);
    } 
} 
//CodeSnippet.10

而PlayerState 创建以后,也会执行 PostInitializeComponents 方法,将自己放入 GameStateBase 中; 这样 GameStateBase 就能获取到所有加入到游戏的玩家信息(通过 PlayerArray), 通过玩家信息又能获取到对应的 PlayerController, 通过 PlayerController, 就能获取到 UPlayer (type is UNetConnection)

生成PlayerControllery以后,接下来调用GameMode->PostLogin, 这里就会生成最终我们控制的Pawn

Create Pawn In Client

Client 在收到 NMT_Welcome 消息后开始加载地图,CodeSnippet.6内 SpawnPlayActor() 被调用,之后的流程和 Server 一致

小结

GamePlay的初始化流程分为两个大的阶段:

  • LoadMap 之前: 生成 GameEngine, GameInstance, WorldContext, GameViewportClient(Client Only) , LocalPlayer(Client Only)
  • LoadMap 之后: URL.umap资源序列化以后的对象(UWorld, ULevel, AActor, UActorComponent…), GameModeBase(Server Only), GameSession (Server Only) , GameStateBase, GameNetworkManager (Server Only) , PlayerController, PlayerState, Pawn

LoadMap 之前生成的对象只有一个, GameInstance 的数量在 PIE 模式下有点特殊, 会根据 Number of Players 生成多个(Play Offline/Play As Listen Server: n , Play As Client:n+1)

至此,GamePlay启动流程基本结束。如果对文中一些类的具体用途以及为什么如此设计,则可以翻阅大钊老师的专栏《InsideUE5》,里面详细讲解了GamePlay框架中最重要的几个类的设计原理以及具体用途

本文的内容主要来自youtube上一位大佬的视频讲解(有好心人搬到了B站), 最好的学习方法就是照着视频的内容,一步步看源码然后记录下来,记载成文加深记忆和理解

youtube:The Unreal Engine Game Framework: From int main() to BeginPlay
bilibili:UE4游戏框架:从int main()到BeginPlay

[原创]C++基础系列—const和constexpr

constant 意思是”常量”, 而 const 和 constexpr 这两个关键字和 constant 有一定的关系。常量的广义定义是:不变化的量(比如:重力加速度,圆周率,光速等)。说这两个关键字之前,我们还得提一下同样可以定义常量的关键字:#define

#define

你可能见过如下代码:

# include <stdio.h>
# define PI 3.14
int main(void)
{
    float s;
    s = PI * 5 * 5;
    printf("s = %.6f\n", s);
    return 0;
}

此时,PI 的作用就是一个常量。这样写的好处是凡是代码中用到圆周率计算的地方,直接用 PI 替换,要扩展圆周率的精度,只要修改 PI 的值就行。#define定义常量也有一些问题:

  • #define 是宏,它仅仅是做字符匹配替换,没有语法检测;而且替换以后通常是纯右值,无法当作变量使用
  • 无作用域,容易字符冲突(比如:Max,Min)

当然,#define 也有以下一些优势:

  • 防止头文件重复包含(#ifndef XXX #define XXX #endif)
  • 条件编译(ifdef XXX… #else …#endif)

const

const关键字,其实有两种语义

  • 表示常量 const float PI = 3.14f
  • 修饰的对象不可改变

const也可以修饰函数,表示当前函数不允许修改被调用对象的值(注意: const 函数内不可调用非 const 函数)

const还可以修饰指针,它所在的位置不同,语义也有不同(*左定值,*右定向) :

  • 修饰指针指向的内容,则内容为不可变量 const int* p = &a; *p =3; //error
  • 修饰指针,则指针为不可变量 int* const p = &a; p = &b; //error

在C++11引入constexpr以后,官方不推荐用const表示常量,因此也可”片面”的认为,const关键字其实就是C#的关键字:readonly

constexpr

这是在C++11引入的关键字,在C++20以后,限制就越来越少,甚至可以修饰 lamda 表达式。它和const最主要的区别是:constexpr所修饰的常量必须是编译期常量(注意:constexpr 所修饰的函数,返回值则不一定要求是编译期常量),如下代码:

const int len = 5; //不推荐
constexpr int len = 5; //推荐
int a[len];

len是一个编译期就需要知道的常量,const 并不一定编译期就可获取它的值,因此严谨的写法应该是下面那种。至于constexpr的其他用处,那就得在元编程中大显身手了,这不在本篇基础文章讲解范围内

[原创]C++基础系列—右值引用

既然有了左值引用,看似一切都很美好了。为什么还需要右值引用这个东西呢?引入左值引用,解决了运算符重载的问题;C++11引入右值引用,则是为了追求极致性能

左值( lvalues ) 右值( rvalues )

为了了解右值引用,我们再次明确一下,左值和右值的概念。 Lvalue Persist;Rvalue Are Ephemeral《C++ Primier》,更简单的概念就是:左值可获取存储地址;右值不可获取地址,只能获取值本身。详细判断左值右值的地址:Value categories

左值引用

左值引用,只能绑定左值,注意下面例子最后一个,1是右值没错,但其实它可以分解为注释后的两句代码,因此也就能理解,它并没有超脱左值引用的定义

int foo = 1;        //foo is lvalue, 1 is rvalue
int* bar = foo;     //foo is lvalue , bar is lvalue
int* baz = 1;       //error: 1 is rvalue
const int* qux = 1; // const int temp = 1, const int* qux = temp;

右值引用

右值引用,只能绑定右值(也就是绑定的是 生命周期在下一行就会结束的临时值)

int foo = 1;
int&& baz = foo;      //error: foo is lvalue
int&& qux = 1;        //1 is rvalue
int&& quux = foo * 1; //(foo * 1) is rvalue
int&& quuz = foo++;   //foo++ return a rvalue

右值引用它能绑定临时值,本来在下一行之前就会”消失”的数据,我们能再次利用,最重要的一点:可以任意用它而不用担心会影响其它地方;可能还是一头雾水,我们通过一个例子来展示一下:

#include <iostream>
#include <vector>
using namespace std;

class Foo
{
public:

    Foo() { data = nullptr; }

    Foo(const char* str)
    {
        initdata(str,"构造");
    }

    Foo(const Foo& foo)
    {
        initdata(foo.data, "拷贝构造");
    }

    Foo& operator=(const Foo& foo)
    {
        initdata(foo.data, "拷贝赋值");
        return *this;
    }

    ~Foo()
    {
        if (data != nullptr) {  free(data); }
    }

private:

    void initdata(const char* str, const char*  des)
    {
        data = new char[strlen(str) + 1];
        memcpy(this->data, str, strlen(str));
        data[strlen(str)] = '\0';
        cout << des << data << endl;
    }

    char* data;
};

int main()
{
    Foo foo = Foo("hello");
    Foo bar = foo;

    vector<Foo> vec;
    vec.push_back(Foo("world"));
}

打印结果:
构造hello
拷贝赋值hello
构造world
拷贝构造world

每一次都有 new char[] 操作,相当于有4次内存申请操作,我们在Foo中插入以下代码;再输出:

Foo(Foo&& str)
{
    cout << "移动构造" << str.data << endl;
    data = str.data;
    str.data = nullptr;
}

Foo& operator=(Foo&& str)
{
    cout << "移动拷贝赋值" << str.data << endl;
    if (this != &str) {
        this->data = str.data;
        str.data = nullptr;
    }
    return *this;
}

打印结果:
构造hello
移动拷贝赋值hello
构造world
移动构造world

虽然也有4条输出,但实际 new char[] 只执行了2次。移动拷贝赋值 和 移动构造 里面没有 new char[] 操作,只是指针重定向。你可能会有疑问:你内部实现方式都不一样,那肯定不需要 new char[] 啊 ?很高兴,你已经Get到点了,那为什么可以直接重定向而不需要 new char[] ?

因为它的参数是右值引用。请翻到上文加粗部分,右值引用它绑定的是临时值,它不会被其它任何地方的代码引用,因此可以放心将它的 data 指向自己而不用担心会牵扯其它地方。

此刻你是不是灵机一动?想到了右值引用的核心了:就是告诉编译器,这个”值”已经没人疼没人爱,你带走随便操作吧。那是不是我可以把我认为后面我不会再用到的值,直接转成右值引用呢?是的,可以。上个示例,main函数改成如下:

int main()
{
    Foo foo = Foo("hello");             //第一行
    Foo bar = static_cast<Foo&&>(foo);  //第二行
    return 0;                           //第三行
}

在第三行断点,你可已发现,foo内的 data 值已经变为NULL,hello 从foo “转移”到了 bar 。这就引出了经常讨论的 move semantics (移动语义)

move semantics(移动语义)

其实移动语义,就是上文main函数的第二行,更改如下:

int main()
{
    Foo foo = Foo("hello");  //第一行
    Foo bar = std::move(foo);//第二行
    return 0;                //第三行
}

std::move(foo) 等于 static_cast<foo&&>(foo) ,就是将左值强转为右值引用。就是告诉编译器,这个左值绑定的值,左值再也不对它负责。因此,第二行以后就不应该再使用 foo 做一些操作,可能导致崩溃

那既然这样,为什么还要std::move() 语义?直接 static_cast<T&&>() 不就好了吗?两点原因:

  • std::move() 语法更简洁,方便代码理解
  • std::move() 不需要显示书写类型(T&&)

注意:右值引用本身是左值,这是啥意思呢?代码如下:

void func(Foo&& foo)
{
    //foo do something
}

int main()
{
    Foo foo =  Foo("hello");
    Foo bar = std::move(foo);
    func(bar); //error     //第四行
    func(Foo("hello"));    //第五行
    func(std::move(foo));  //第六行
    func(std::move(bar));  //第七行
    return 0;
}

bar 变量本身是左值,可以取地址 (虽然他绑定的是一个右值引用) ,因此第四行的代码编译报错。move 除了使用在移动构造,还可以用于智能指针。而且 std::swap就是通过move实现的,伪代码如下:

template<typename T>
void swap(T& a,T& b) noexcept
{
    T temp = std::move(a);
    a = std::move(b);
    b = std::move(temp);
}

至此,我们已经知晓左值引用,右值引用,而且语义明确,不会有任何歧义。但是在C++中,有个核弹——模板,它让情况变复杂了

万能引用

一个方法的形参,类型要么是左值引用,要么是右值引用

void func(Foo& foo) {}
void func(Foo&& foo){}

有没有同时接受左值引用和右值引用的方法呢?有的,万能的模板可以实现你的愿望

template<typename T>
void func(T&& param) 
{
    cout << param << endl;
}

int main() 
{
    int foo= 1;
    func(foo); //foo的类型:int&;(因为func只接受引用)
    func(1); //数值1的类型:int&&
    return 0;
}

是不是很神奇,将 T 替换成我们传入的类型,那么函数实际参数类型如下:

void func(int& && param) 
{
    cout << param << endl;
}
void func(int&& && param) 
{
    cout << param << endl;
}

“& &&” 和 “&& &&” 是不存在的类型,如下定义全都会报错:

(int&)&& foo;
int& &&  bar;
int&&&   baz;

那 T&& 为啥可以呢?因为C++ 11 定义了折叠引用规则

引用折叠(Universal Collapse)

func(foo) 传入的 T 推导类型:int&,因此实际类型是:int& &&,这是个不存在的类型,因此 C++ 标准强行规定了一个规则:

如果任一引用为左值引用,则结果为左值引用;否则结果为右值引用。 有点不好理解,我换个说法,左值引用是 0 ,右值引用时 1,折叠规则就是:左值引用 and 右值引用(这里的and是算数操作符 与 ),因此:

int& && = 0 and 1 = int&
int&& & = 1 and 0 = int&
int& & = 0 and 0 = int&
int&& && = 1 and 1= int&&

这也是为什么万能引用可以既接受左值引用又可以接受右值引用的原因。但是请注意折叠引用仅适用于编译器自己推出来的类型,手动定义的类型编译器不会遵循折叠引用,因此上文的定义 (int&)&& foo; int& && bar; int&&& baz; 都会报错

既然万能引用技能接受左值引用,又能接受右值引用。那有个问题,如果我func里面要调用其它方法,将 param 当作参数传给它,那么它的类型是不是就不对了呢?

完美转发(Perfect Forwarding)

template<typename T>
void bar(T& param) 
{
    cout << "param is lvalue" << endl;
}

template<typename T>
void bar(T&& param)
{
    cout << "param is rvalue" << endl;
}

template<typename T>
void foo(T&& param)
{
   bar(param);
}

int main() 
{
    int baz = 1;
    foo(baz);
    foo(1);
    return 0;
}
//Consloe:
//param is lvalue
//param is lvalue

结果都是左值,为什么呢?因为 param 相当于一个变量,它绑定的是传进来的T&&类型的值(无论这个值折叠以后是什么类型,变量总是左值)。这显然和我们的期望相违背,那怎么办呢?再强转一次呗

template<typename T>
void foo(T&& param)
{
    bar(static_cast<T&&>(param)); //替换bar(param);
}

将 param 强转回 T&& 类型,再传给 bar 函数,编译器就能准确找到对应的函数。同样,static_cast<T&&>(),也有对应的简洁写法,std::forward<T>()

总结

C++ 11引入右值引用,主要目的是为了提高性能(弥补 拷贝构造,拷贝赋值等操作必须进行内存申请等很消耗性能的操作),最大限度的利用了临时值。而移动语义,完美转发 的本质其实就是类型转换 static_cast<T&&>() ,并不神秘

[原创]C++基础系列—左值引用

有没有想过一个问题,为什么C++ 有了指针( Pointer),仍然引进 “引用(Reference)” 这个概念?C++之父的解释:

C++ inherited pointers from C, so I couldn’t remove them without causing serious compatibility problems. References are useful for several things, but the direct reason I introduced them in C++ was to support operator overloading (简译: C++ 为了兼容 C,所以不可能会移除指针;引用在很多地方有用处,但主要是为了解决运算符重载 )

operator overloading (运算符重载)

运算符重载为什么必须要用到引用呢?我们看看下面一个实例,你就能理解为什么需要引用

#include <iostream>
using namespace std;

class Box
{
public:
    int length;
    Box(int len){ length = len; }

    //值方式重载
    Box operator+=(const Box b)
    {
        Box box(0);
        box.length = this->length + b.length;
        return box;
    }

    //引用方式重载
    Box& operator+=(const Box& b)
    {
        this->length = this->length + b.length;
        return *this;
    }

    //指针方式重载
    Box* operator+=(const Box* b)
    {
        this->length = this->length + b->length;
        return this;
    }
};

Box GetBox() { return Box(1); }

int main()
{
    Box box(1);
    //值方式重载示例
    (box += box) += box; //result:1
    //引用方式重载示例
    (box += box) += box;//result:4
    //指针方式重载示例
    *(box += &box) += &box;//result:4
    //只有引用方式的重载才能正确编译
    box += GetBox();
    cout << "box.Length = " << box.length << endl;
    return 0;
}

值方式重载有两个问题:

  • 构造函数被调用多次;浪费性能
  • 功能未实现;(box += box) += box 结果不符合预期

指针方式重载也有以下两个问题:

  • 写法不够简洁,晦涩难懂
  • box += GetBox() 编译不过(不能用 “右值” 当实参)

函数参数

C++ 之父说还说,引用在有些地方也很有用。除了重载运算符,作为函数参数也很有用,示例如下:

void Func(Foo foo);//有拷贝构造,不推荐
void FuncPtr(Foo* foo);//无拷贝构造,但是foo使用之前需判空
void FuncRef(const Foo& foo);//无拷贝构造,官方推荐(可传入右值实参)

你是不是以为就这了?其实它还有个隐藏的用处,请看以下代码:

void foo(const int& value)
{
    int temp = value + 1;
}

void bar(const int* value)
{
    int temp = *value + 1;
}

int main()
{
    int value = 1;
    int& ref = value;
    int* ptr = &value;

    foo(2);
    foo(value);
    foo(ref);

    bar(2);      //error
    bar(value);  //error
    bar(ptr);    //error
    return 0;
}

函数 foo(const int& value) 可以接受三种实参, 这一点在模板里面很有用 ;而 bar(const int* value) 只能接受指针类型实参

函数返回值

还有没有其它用处呢?我觉得写单例的时候会用到。以下是 C++ 单例的 “标准” 写法:

class Singleton 
{
 private:
    Singleton (void)

 public:
    static Singleton& getInstance()
    {
        static CSingleton m_pInstance;
        return m_pInstance;
    }
};

为什么引用的单例比指针的更好?因为安全,返回的是引用不可被更改,而且不会为空

总结

综上,引用的作用有以下地方:

  • operator overloading;不仅是为了简化语法,还是为了实现功能
  • 作为函数形参
  • 作为函数返回值

当然,我们这里讨论的都是普通引用,也就是左值引用;C++ 11以后引入了右值引用,初看很迷惑,实际也是为了解决问题,下一篇我们再来谈谈

[原创]UE— Garbage Collection

这是一篇长文,涵盖了UE4垃圾回收的大部分知识点。如若有错误之处,望留言纠正(亦可邮件探讨:lingzelove2008@163.com),感激不尽

垃圾回收起源

何为垃圾回收 (Garbage Collection) ?它是一种自动管理资产 (Assets) 生命周期的运行时机制

UE 采用的编程语言是 C++,这也是很多 Unity 程序员转行到 UE 的第一个重量级拦路虎。有好心的 UE 前辈会告诉你 : “UE 的 C++ 是魔改版,用起来跟 C# 差不多”。这句话有一定的道理,让前辈们有这种错觉最重要的一点就是,UE 引擎实现了垃圾回收(C++标准未实现 )

C++的内存管理,new 必须对应 delete ; 但是游戏项目非常庞大,指针满天飞,你压根不知道应该在哪儿 delete 。隔壁 Unity 只需要 new 系统会自动 delete,这让 UE 引擎专家甚是羡慕,就强撸了一个垃圾回收系统来自动 delete,让 UE 的开发难度骤降

垃圾回收的功能

如果让你来实现这个垃圾回收系统,你觉得垃圾回收系统应该具备哪些功能呢?

  • 实现自定义的 NewObject 方法( 无需调用对应的DeleteObject )
  • 自动回收没有被引用的垃圾对象
  • 不影响其他系统的正常功能

垃圾回收伪实现

垃圾回收的算法有很多, 标记-清除算法 、 复制算法 、标记-整理算法 、分代收集算法等。我们就用最”简单”的标记清除算法来实现。标记-清除算法,看名字就知道有两个阶段,标记和清除:

  • 标记:遍历所有对象,根据某种规则,标记其是否需要清除
  • 清除:遍历所有对象,清除标记了的对象,回收内存

因此可知,要实现标记清除垃圾回收,在标记阶段我们需要做到以下两点:

  • 能拿到所有对象
  • 确定对象清除的规则

在自定义的 NewObject 方法内,把生成的对象指针放入全局数组 GUObjectArray ,这样我就能拿到所有对象了

想象一个画面:空场景内站着一个英雄。这个情况下,垃圾回收系统是不是应该围绕着英雄来判断?英雄用到的对象就保留,没用到的对象就清除。此时这个英雄就是 “根对象”。因此,标记清除的规则就是,根对象用到的对象保留,其他对象清除。那根对象怎么确定?就得我们”手动”标记(AddToRoot)

以下就是垃圾回收的伪实现:

  • 启动垃圾回收,加锁( 保持所有对象的引用关系不变 )
  • 设置所有对象为”不可达”标记(根对象、特殊对象 除外)
  • 遍历根对象列表,根对象引用到的对象去除”不可达”标记
  • 收集所有仍然标记为”不可达”的对象,全部删除

垃圾回收 UE4 实现

UE4的垃圾回收过程,正是照着上文的伪实现一步步执行的,不过细节更丰富,效率也更高

GC 启动

手动调用:UWorld::ForceGarbageCollection( bool bFullPurge),它会在World.tick 的下一帧强行进行垃圾回收

自动调用:系统会根据默认的设置(可重新配置)一定的间隔时间或者条件下,自动调用垃圾回收

GC 锁

void CollectGarbage(EObjectFlags KeepFlags, bool bPerformFullPurge)
{
    AcquireGCLock(); //获取GC锁
    CollectGarbageInternal(KeepFlags, bPerformFullPurge); //垃圾回收
    ReleaseGCLock(); //释放GC锁
}

GC锁的主要用处就是为了暂停其他线程以免UObject对象的引用关系在GC过程中发生变化。主要步骤:

  • 发送信号,表示我想获取GC锁,GCWantsToRunCounter 自增(原子操作)
  • GC 线程 Sleep,查看 AsyncCounter 是否等于 0 判断其他线程是否有阻塞GC的操作还在执行,不等于 0 就继续等待
  • AsyncCounter = 0,通过另一个变量 GCCounter 递增(原子操作),来标识正在执行GC,其他所有线程将被阻塞
  • 执行内存屏障
  • 将 GCWantsToRunCounter 设为 0,开始真正的 GC 操作
  • GC 操作完毕, GCCounter 自减释放 GC 锁

内存屏障的主要意思就是,在这个屏障之前的所有读和写的操作,一定会在这个屏障后面的读和写的操作之前执行。为了防范多线程读写操作时序问题导致的逻辑 bug,详细内容自行 Google

获取所有对象

上文伪实现,NewObject的时候把生成的对象的指针放入一个全局数组,实际上UE4确实也是这么做的。UObject对象继承自UObjectBase,它的构造方法如下(代码有删减改动):

//UObjectBase.cpp

//NewUObject方法调用后,UObject对象初始化
UObjectBase::UObjectBase(UClass* InClass, EObjectFlags InFlags, EInternalObjectFlags InInternalFlags, UObject *InOuter, FName InName)
{
   AddObject(InName, InInternalFlags); 
}

void UObjectBase::AddObject(FName InName, EInternalObjectFlags InSetInternalFlags)
{ 
   //将对象加入GUObjectArray,并且为Object分配InternalIndex(对象的索引位置)
   GUObjectArray.AllocateUObjectIndex(Object);
}

GUObjectArray 虽然命名是 Array 结尾,实际上它是个容器体。虽然它里面一堆花里胡哨的东西,实际上只要理解它是为了多线程的时候分块扫描其存储的内容而设计就行。UObject对象不接存入容器的,而是被组装成了 FUObjectItem 结构:

//UObjectArray.h

//对象存储的结构体,GC操作的就是这个对象
struct FUObjectItem
{
   class UObjectBase* Object; //对象
   int32 Flags;               //EInternalObjectFlags标识
   int32 ClusterRootIndex;    //当前所属簇索引
   int32 SerialNumber;        //对象唯一序列码(WeakObjectPtr实现用到它)
}

这个结构体对于理解GC很重要。成员 Object 是 NewObject 生成的对象的指针,EInternalObjectFlags 是啥呢?就是用来做标记的枚举类型。结构如下:

//ObjectMacros.h

enum class EInternalObjectFlags : int32
{
   None = 0,
   ReachableInCluster = 1 << 23, ///< 簇中可达
   ClusterRoot = 1 << 24, //cluster root 不会被GC回收,簇根节点
   Native = 1 << 25, // UClass
   Async = 1 << 26, //异步对象
   AsyncLoading = 1 << 27, //异步加载中
   Unreachable = 1 << 28, // 不可达对象,会被GC删除
   PendingKill = 1 << 29, // 等待析构,会被GC删除
   RootSet = 1 << 30, // 根节点
   
   GarbageCollectionKeepFlags = Native | Async | AsyncLoading, //拥有这三种标记之一的对象在GC检测时会被跳过
};

标记不可达

以下代码就是获取所有对象,并根据标签 标记部分对象为不可达;可达对象都放入 ObjectsToSerialize 数组内 ( 代码有删减,部分变量名用auto替换,是为了减少长度)

//GarbageCollection.cpp

void PerformReachabilityAnalysis(EObjectFlags KeepFlags, bool bForceSingleThreaded, bool bWithClusters)
   {
      //从系统提供的数组池中获取数组(为了支持多线程)
      auto ArrayStruct = FGCArrayPool::Get().GetArrayStructFromPool();
      auto ObjectsToSerialize = ArrayStruct->ObjectsToSerialize;

      // 继承了FGCObject的非Uobject对象,放入ObjectsToSerialize
      ObjectsToSerialize.Add(FGCObject::GGCObjectReferencer);

      //将对象标记为不可达,并且将根节点以及不可删除对象放入ObjectsToSerialize
      MarkObjectsAsUnreachable(ObjectsToSerialize, KeepFlags);
      
      //分析ObjectsToSerialize数组内的对象,它能达到的对象,去掉不可达标签
      PerformReachabilityAnalysisOnObjectsInternal(ArrayStruct)
   }

代码都有注释,很好理解;ObjectsToSerialize.Add(FGCObject::GGCObjectReferencer); 这一行,很关键,这也是为什么你看很多文章,都说非UObject对象,继承 FGCObject 后,也可以将它引用的对象加入垃圾回收

class FMyStruct: public FGCObject
{
    UObject* NoGCObj;

    void AddReferencedObjects(FReferenceCollector& Collector) override
    {
	Collector.AddReferencedObject(NoGCObj);
    }
}

这部分的代码分析,可见我的上一篇文章:[原创]UE —UObject类智能指针

引用关系分析

MarkObjectsAsUnreachable() 方法消耗不大,因为是多线程操作,且这些 FUObjectItem 结构体对象是内存块连续的数据,遍历不会有缓存 miss。PerformReachabilityAnalysisOnObjectsInternal(ArrayStruct) 才是最复杂,最耗时的操作

UE 是怎么获取一个对象引用了哪些其他对象呢?

UE 中每个 UObject 对象都有一个与之对应的 UClass , 这个UClass保存了对应UObject 的所有反射系统相关信息,描述了各个 Property 之间的内存布局和关系;通过UObject对象的实例化地址就可以将相应的属性遍历出来,进行读写(注意:UClass描述的属性是我们手动加了 UPROPERTY() 标签的)

Obj 对象所引用的其他 UObject 对象,伪代码如下:

//遍历Obj对象的所有属性,如果属性的类型是UObjectle类型,就取消不可达标签
for(TFieldIterator<FProperty> PropertyIter(Obj->GetClass()); PropertyIter; ++PropertyIter)
{
    const FProperty* PropertyIns = *PropertyIter;
    //该属性是否是UObject,是否是Tarray<UObject>,是否是TMap<key,UObject>等
    //然后依次取消 "不可达"标记
    //然后递归遍历该属性对象所引用的对象......
}

这么实现,确实可以 ;但是 UE 并没有这么做,为什么呢?因为属性内其实大部分都是非 UObject类型 ,全部遍历效率太低。因此在生成 UObject对应 UClass 的时候,就构造了一个新的概念,将所引用的其他对象用一个很巧妙的整数,存入 ReferenceTokenStream 变量的 Tokens 数组内

ReferenceTokenStream

断点一个UObject对象,查看其ReferenceTokenStream,内容如下:

这个里面包含当前对象 Obj 引用到的所有标记了 UPROPERTY() 的属性(包括它的父类),那这个数字到底是啥意思呢 ?其实它是三个数字的合并。前8位, 引用对象的嵌套深度 ;中间5位,引用对象的类型( EGCRefenceType );最后则是当前引用的对象的变量在 Obj 对象内的内存偏移值(根据这个偏移值,可获取这个引用的对象)

struct FGCReferenceInfo
{
    union
    {
        struct
        {
            uint32 ReturnCount  : 8;
            uint32 Type         : 5;
            uint32 Offset       : 19;
        };
        //ReturnCount + Type  + Offset
        //00000000    + 00000 + 0000000000000000000
        uint32 Value; 
    };
};

根据 Tokens 内的值,我们可以获取它引用的属性的类型。比如: GCRT_ArrayObject 、 GCRT_ArrayStruct、 GCRT_Object等,根据它们的类型做不同的操作。PerformReachabilityAnalysisOnObjectsInternal 代码内最后调用的分析代码是 ProcessObjectArray (以下代码有删减):

void ProcessObjectArray(FGCArrayStruct& InObjectsToSerializeStruct, const FGraphEventRef& MyCompletionGraphEvent)
{
    while (CurrentIndex < ObjectsToSerialize.Num())
    {
        CurrentObject = ObjectsToSerialize[CurrentIndex++];
        // 获取当前对象的ReferenceTokenStream
        auto* TokenStream = &CurrentObject->GetClass()->ReferenceTokenStream;
        uint32 ReferenceTokenStreamIndex = 0;
        //当前对象的起始地址
        uint8* StackEntryData = (uint8*)CurrentObject;
        while (true)
        {
			//注意:源码这里不是直接++,但是为了方便理解,这里直接用
	    ReferenceTokenStreamIndex++;
	    FGCReferenceInfo ReferenceInfo = TokenStream->AccessReferenceInfo(ReferenceTokenStreamIndex);
            switch(ReferenceInfo.Type)
            {
                case GCRT_Object:
                case GCRT_Class:
                {
                    // 引用对象的地址: 起始地址 + Offset
                    UObject**   ObjectPtr = (UObject**)(StackEntryData + ReferenceInfo.Offset);
                    UObject*&   Object = *ObjectPtr;
             ReferenceProcessor.HandleTokenStreamObjectReference(Object...);
                }
                break;
                case GCRT_ArrayObject:
                {
                    TArray<UObject*>& ObjectArray = *((TArray<UObject*>*)(StackEntryData + ReferenceInfo.Offset));
                    for (int32 ObjectIndex = 0, ObjectNum = ObjectArray.Num(); ObjectIndex < ObjectNum; ++ObjectIndex)
                    {
          ReferenceProcessor.HandleTokenStreamObjectReference(Object...);
                    }
                }
                break;
            }
        }
    }
}

HandleTokenStreamObjectReference 会调用 HandleObjectReference,它会去除它的”不可达”标记,并将它加入NewObjectsToSerialize,开辟新的 task 线程去处理,而不是在当前线程递归

清理

GC 的清理过程,在函数 IncrementalPurgeGarbage 内,它的两个参数可以让我们选择是增量清理还是全量清理,清理的过程分为两步:收集 和 清理

收集不可达对象:GatherUnreachableObjects,多线程操作, 遍历GUObjectArray 将所有标记为”不可达”的 FUObjectItem 加入 GUnreachableObjects 数组

清理看起来实现应该很简单, 直接遍历 GUnreachableObjects 数组,然后delete就行,实际很复杂,具体步骤如下:

  • 获取 GC 锁(因为 BeginDestroy 到 FinishDestroy 之间可能会有异步操作)
  • 执行 UnhashUnreachableObjects 方法,遍历数组GUnreachableObjects,调用对象的 BeginDestroy 方法,可重载这个方法进行自定义操作
  • 执行 IncrementalDestroyGarbage 方法,遍历数组 GUnreachableObjects ,调用对象的 FinishDestroy 方法,注意:此时可能 BeginDestroy 内的异步方法还没执行完毕,因此 FinishDestroy 方法会执行失败,先把对象存储到 GGCObjectsPendingDestruction 中,之后再处理
  • 遍历数组 GGCObjectsPendingDestruction, 尝试调用对象的 FinishDestroy 方法
  • TickDestroyGameThreadObjects 执行真正的删除对象,遍历 GUnreachableObjects 内的所有对象;执行之后的操作,代码如下:
// TickDestroyGameThreadObjects 对垃圾对象最后的处理 
FUObjectItem* ObjectItem = GUnreachableObjects[ObjCurrentPurgeObjectIndexOnGameThread];       
if (ObjectItem)
{
   //将GUObject内的对象设置为nullptr
   GUnreachableObjects[ObjCurrentPurgeObjectIndexOnGameThread] = nullptr;
   UObject* Object = (UObject*)ObjectItem->Object;
   //调用析构函数
   Object->~UObject();
   //释放内存
   GUObjectAllocator.FreeUObject(Object);
}

至此,本文告一段落。当然,关于垃圾回收还有很多未解之谜。比如:神秘的UClass 是怎么生成的 ?它又是如何收集 UObject 的属性(标记了 UPROPERTY ),方法(标记了 UFUNCTION) ?ReferenceTokenStream是如何生成的 ? Cluster (簇) 是什么 ,它对GC有何影响?这些巨坑,等以后再慢慢填

[原创]UE—GAS系列:1. 基础概念

UE的技能系统 ( Gameplay Ability System 简称:GAS ) ,官方是以插件的形式嵌入在UE引擎中;你完全可以在自己的项目中用使用自己团队的”祖传”技能系统,但我还是推荐大型项目优先使用 GAS 。它的优点是:度高灵活、可复用性强;缺点是:过于庞大、不易上手

如果你是第一次接触 GAS,可能被一些专有名词给绕晕 :GameplayAbility(GA)、 AbilitySystemComponent(ASC) 、GameplayEffect (GE)、AttributeSet(AS)、 GameplayCue (GC); 那么,我就通俗易懂的解释一下这些概念

GameplayAbility(GA)

武当有一门神技《梯云纵》,施展以后就能一跃十丈,潇洒无边。这本秘籍就是GA ,我们来看看这本秘籍的内容:梯云纵(第一重)

神技心法:

  • 气沉丹田
  • 聚气成力

神技说明:

  • 每天只能施展一次
  • 每次施展消耗一点真气

神技威力:

  • 一跃而起,离地一丈
  • 形成雷鸣,方圆一里

AbilitySystemComponent(ASC)

想要习得上乘武功,就必须有习武的神经中枢(俗称:天赋),没有天赋只有秘籍那就是个王语嫣;而这个神经中枢就是ASC

因此想要施展技能,Character 必须挂上ASC 组件,神经中枢是通过 GiveAbility 融汇贯通了梯云纵的神技心法

ASC->GiveAbility(const FGameplayAbilitySpec& Spec);
FGameplayAbilitySpec 主要参数如下:
UGameplayAbility* GA;  //梯云纵
int32 Level;           //第一重
UObject* SourceObject; //谁习得梯云纵(自己)

GameplayEffect (GE)

融汇贯通了梯云纵心法,那是不是就可以出门行侠仗义,闯荡江湖了呢?还是得从长计议,神技虽然威力无边,但也有很大的自身限制,因为神技第一重每天只能使用一次。用GAS它是怎么实现的呢?那就要用到 GE 了

GE 通俗一点理解就是 Buff,DeBuff ;施展完一次梯云纵以后,GA会告知 ASC,请在小本本上写一行标记: CoolDown.Skill.TiYunZong ,然后24小时后删掉这行标记。伪代码如下:

//GE相当于一份配置文件,包含ASC要记的标记,持续时间等要素
UGameplayEffect* CooldownGE = GetCooldownGameplayEffect();
if (CooldownGE)
{
    const auto SpecHandle = MakeOutGESpec(CooldownGE->GetClass(), 1);
    ApplyGameplayEffectSpecToOwner(ActivationInfo, SpecHandle);
}

施展梯云纵之前,ASC 首先会去小本本检查是否有 CoolDown.Skill.TiYunZong 这个标签。如果有那就表示距离上次使用未超过一天时间不得再次施展,这就是GAS 进行技能冷却的原理

AttributeSet(AS)

施展梯云纵之前,中枢神经ASC还会去检查你体内是否有足够的真气,而真气就是 AS,也就是GAS中常说的属性点,比如: 真气(Mana),血量(Health) 等。那么扣一点属性点,是怎么实现的呢?同样是使用 GE 告知 ASC,你扣除身上的Mana属性值 1 点,瞬间完成。伪代码如下:

UGameplayEffect* CostGE = CostEnergyGE;
const auto SpecHandle = MakeOutGESpec(CostGE->GetClass(), 1);
if (SpecHandle.Data)
{
    //代码有删改,意思就是设置GE内对应tag扣的值
    SpecHandle.Data->SetSetByCallerMagnitude("Data.Energy.Mana", 1);
    ApplyGameplayEffectSpecToOwner(ActivationInfo, SpecHandle);
}

这样,就完成了属性点扣除。同样,在梯云纵施展之前,出了要检查是否有 CoolDown.Skill.TiYunZong 这个标签,ASC 还会去检查属性点 Mana 是否足够你施展完以后扣除,不够就不能施展;强行施展后会抽走全身精气走火入魔

GameplayCue (GC)

施展梯云纵的时候,由于它威力巨大,会在方圆一里产生巨大的雷鸣,震撼八方 宵小之辈。那么GAS 是如何实现的呢?也是通过 GE 告知 ASC,丢出去一个一次性暗器 ( 也就是GC ),它会播放一个3D环绕雷声(声音范围:方圆一里)以及雷电特效,播放完以后自动销毁 。GE 配置了丢哪个暗器(Gameplay Cue Tag),以及何时销毁(Has Duration)

总结

注意: ASC 组件不一定非得挂在 Character 身上,你也可以挂在 PlayerState 身上,甚至游戏内任何想要使用技能的 Actor 身上。GE 是 GAS 的灵魂,也是重中之重,很多效果都是通过 GE 去驱动。GAS 还有一个核心 — FGameplayTag,但这是UE4引擎本身的标签系统;如果没有 FGameplayTag 配合,GAS 就显得平平无奇;FGameplayTag + GAS 组成强大的技能系统,可以实现任何上天入地的炫酷技能

至此,我们可以基本上明白GAS内的各个名词的功能。但实际上,这才是梯云纵的第一层,想要达到大圆满境界,我们仍需努力。以下是我觉得比较好的一些 GAS 视频链接,适合新手登堂入室

GAS新手链接:

[原创]UE —UObject类智能指针

上文谈到C++ 标准智能指针,是为了让程序员从指针的 new 和 delete 中抽身,更专注逻辑本身的开发。UE4 针对非 UObject 对象也实现了一套类似的智能指针,至于具体实现原理,可查看知乎文章:UE4的智能指针 TSharedPtr

强如 Java 和 C# 等更“智能化”的高端语言已经实现了垃圾回收机制,恐怖如斯;UE虽身披 C++ 却心向 C# ,自己也捣鼓了一个低端版本的垃圾回收,针对UObject类型对象的智能指针,主要有:TWeakObjectPtr,TStrongObjectPtr

WeakObjectPtr

我们知道继承了 UObject,并且被标记了 UPROERTY 的对象 Obj ,会被 GC 追踪(会被GC追踪的对象还有其它情况,本文暂不讨论);因此我们在使用 Obj 之前一般都会有如下代码安全开道:

if (IsValid(Obj))
{
    //Use Obj do something
}

GC 的删除过程是分帧处理,但是会提前标记 PendingKill,所以 IsValid(Obj) 依然返回 false,确保我们的代码不会出问题。但是,如果我们的代码是在 GC 清理完全以后再调用呢?上面这段代码就可能会导致 Crash ,为什么呢?

//Object.h
FORCEINLINE bool IsValid(const UObject *Test)
{
    return Test && !Test->IsPendingKill();
}

//UObjectBaseUtility.h
FORCEINLINE bool IsPendingKill() const
{
    return GUObjectArray.IndexToObject(InternalIndex)->IsPendingKill();
}

Obj 指向的 UObject 对象被初始化出来后,会被插入到数组 GUObjectArray,位置就是 InternalIndex 。GC后,GUObjectArray 数组内 InternalIndex 的位置被放入新的 UObject 对象,因此 IsValid ( Obj ) 返回的结果可能是 true;因此IsValid 判断 Obj 指针有效,而实际上 Obj 指针指向的内存地址里的内容解引用会失败,导致Crash

因此,我们希望有一种新型的智能指针Smart_Ptr用来包装 裸指针Obj,它必须满足以下两点要求:

  • Smart_Ptr 不能影响 Obj 的 GC
  • Obj 所指向的对象被 GC 删除以后,IsValid(Smart_Ptr) 会百分百返回 :false

UE很巧妙的撸了个 WeakObjectPtr ,实现原理很简单,它的核心就两个参数:

//WeakObjectPtr.h
int32	ObjectIndex;
int32	ObjectSerialNumber;

//WeakObjectPtr.cpp
ObjectIndex = GUObjectArray.ObjectToIndex((UObjectBase*)Object);
ObjectSerialNumber = GUObjectArray.AllocateSerialNumber(ObjectIndex);

ObjectIndex 就是当前WeakObjectPtr 指向的对象在数组GUObjectArray内的索引,而ObjectSerialNumber 则是该对象当初在创建的时候的唯一序列码 SerialNumber (赋值和自增都是原子操作,线程安全);因为它不直接引用 Obj 指针 ,因此不会影响 GC,然后 ObjectSerialNumber 会跟 GUObjectArray 内 ObjectIndex 位置对象的 SerialNumber 对比,不一样就表示根本不是同一个对象,使用如下:

//Assignment
TWeakObjectPtr<UMyObject> WeakObj = Obj;

//use
if(WeakObj .Get())
{
    UMyObject* Obj = Cast<UMyObject>(WeakObj);
    //Use Obj do something
}

因此,对于一些会隔帧执行的代码(定时器回调、 委托等),如果有用到 UObject 对象,强烈建议使用 WeakObjectPtr,能减少很多隐藏的Crash,让你的代码更乐百氏( Robust )

StrongObjectPtr

相对 WeakObjectPtr 来说, StrongObjectPtr 用的并不多。它提供的是一种绝对的把控,被他包装的 UObject 类型对象(不标记 UPROPERTY ),不会被 GC 自动清理,只有 StrongObjectPtr 析构以后,它包装的 UObject 才会被下一次 GC 清理

因此实现这种指针封装,需要满足以下两点:

  • 持有该对象,并且保证它不被 GC
  • 告诉GC,我用完了你可以清理它

我们查看 StrongObjectPtr 的实现,关键就一个变量:ReferenceCollector,类型是 FInternalReferenceCollector,继承自 FGCObject 。( ReferenceCollector 是 UniquePtr类型指针,因此它不能复制,只能转移所有权(OwnerShip))

为什么继承了 FGCObject 就可以被 GC 追踪 ?我们先看它的实现(注意:为了代码简短,去除了部分内容)

//GCObject.h
class COREUOBJECT_API FGCObject
{
   static UGCObjectReferencer* GGCObjectReferencer;
   static void StaticInit(void)
   {
      if (GGCObjectReferencer == NULL)
      {
         GGCObjectReferencer = NewObject<UGCObjectReferencer>();
         GGCObjectReferencer->AddToRoot();
      }
   }

   FGCObject(void)
   {
       StaticInit();
       GGCObjectReferencer->AddObject(this); 
   }

   virtual ~FGCObject(void)
   {
       GGCObjectReferencer->RemoveObject(this);
   }

   virtual void AddReferencedObjects( FReferenceCollector& Collector ) = 0;
};

静态指针 GGCObjectReferencer,用来管理所有继承了 GFGCObject 的指针对象;初始化执行如下两个方法

  • StaticInit() 内 AddToRoot;表示将 GGCObjectReferencer 设置为根节点,因此它永远不会被 GC 清理(除非调用 RemoveFromRoot)
  • AddObject(this) ,则是将自身放入 GGCObjectReferencer 内的ReferencedObjects 数组内

每一次GC的引用标记阶段,执行到 GGCObjectReferencer 指向的对象时,会执行它的 AddReferencedObjects 方法,而它会遍历调用 ReferencedObjects 数组内的所有 FGCObject 对象的 AddReferencedObjects 方法

//GCObjectReferencer.cpp
void UGCObjectReferencer::AddReferencedObjects(UObject* InThis, FReferenceCollector& Collector)
{	
    UGCObjectReferencer* This = CastChecked<UGCObjectReferencer>(InThis);
    for (FGCObject* Object : This->ReferencedObjects)
    {
	Object->AddReferencedObjects(Collector);
    }
}

我们来看看指针 ReferenceCollector 内的 AddReferencedObjects 方法:

//StrongObjectPtr.h
virtual void AddReferencedObjects(FReferenceCollector& Collector) override
{
   Collector.AddReferencedObject(Object);
}

Collector.AddReferencedObject( Object) 就是将 StrongObjectPtr 所修饰的 UObject 指针对象放入 GC 体系(被 Collector 引用,因此不会被 GC )

以上,我们解释了第一条:持有该对象,并且保证它不被 GC ;那怎么告诉 GC,我现在用完了,可以清理呢?

上面的 ~FGCObject 析构函数已经给到答案了,就是将 ReferenceCollector 从 GGCObjectReferencer 移除。那么下次 GC 引用标记阶段执行到 GGCObjectReferencer 指向的对象时,它的 ReferencedObjects内就没有我们的 ReferenceCollector,那么它的 AddReferencedObjects 方法就不会被执行到; 那么 ReferenceCollector 内的UObject* Object 就没人引用,那么 GC 就会清理它

其实,大部分情况下,都用不到 StrongObjectPtr ,因为 UPROPERTY 标记足够好用。然而它可以在任何地方使用,包括F类内,这也是它的优势

参考引用:

[原创]C++基础系列—智能指针

如果你熟悉 Java 或者 C# ,那么以下代码你肯定不陌生

class Students { public int value = 0; }

void main()
{
 Students a = new Students();
 Students b = a;
 a.value = 10;
//此时,变量 a 和变量 b 的 value 值都是 10
}

很简单,生成了一个Students对象,变量 a 和变量 b 都是对该对象的引用;但是在C++ 中,结果并不是这样

class Students
{
 public:
    int value = 0;
};

void main()
{
    Students a;
    Students b = a;
    a.value = 10;
    //此时,变量 a 的 value 是10,变量 b 的 value 仍是 0
}

编译器会为 class 默认生成 copy constructor 和 assignment operator,C++ 对象默认是可以拷贝的 ,因此 b = a,相当于拷贝了一份全新的对象。那要怎么实现类似Java 或 C# 的效果呢?C++ 就发明了指针这么个东西,它就是指向对象实际存储在内存中的地址,用法如下:

int main()
{
    Students* a = new Students();
    Students* b = a;
    a->value = 5;
    //此时,变量 a 和 b 的value 都是 5
    //delete a;
}

new做的事情就是,调用底层 malloc 获取 Students 类型大小的内存地址(一般会对字节补齐),然后调用 Students 的构造函数(如果没有,编译器会自动补全空构造),返回该对象的内存地址。从此,内存上的这一块地址就永远被占用,除非调用 delete 函数释放。但如果 delete 过早,其他函数还在用这个指针,就可能导致程序崩溃

void foo(Students* ptr)
{
    ptr->value = 10; //crash
}

void main()
{
    Students* ptr = new Students();
    delete ptr;
    foo(ptr);
}

使用裸指针(raw pointer)最大的问题就是,你不知道它当前的所有权(ownership)属于谁,因此也就很难判断应该在哪个位置 delete 它。C++ 编委会的巨佬们本着为了让程序员少掉头发的原则,设计了一个封装指针的类,同时赋予它一个响亮的名字: 智能指针( Smart Pointers )

既然也叫 “指针”,那么就要求它用起来跟普通指针没区别。因此这个封装裸指针的类重载了很多操作符,比如:* ,& ,bool ,== 等。针对各种使用场景,一共设计了三种智能指针类 :

  • std::unique_ptr(single ownership)
  • std::shared_ptr (shared ownership)
  • std::weak_ptr ( no ownership ,与 shared_ptr 伴生关系)

针对上面会崩溃的问题,我们代码改下如下:

void foo(std::unique_ptr<Students> ptr)
{
    ptr->value = 10; //no crash
}

void main()
{
    auto ptr =std::make_unique<Students>();
    foo(std::move(ptr)); 
}
// no memory leak

崩溃解决,同时也不会造成内存泄漏。构造 unique_ptr 的官方推荐的写法是: auto ptr = std::make_unique<class>(); 转移 ownership 的写法是 std::move(ptr); 为了能更详细的理解,我们来写个小实例,并打印相关讯息:

class Students
{
public:
    Students(){ std::cout << "Students初始化创建" << std::endl; }
    ~Students() { std::cout << "Students析构销毁" << std::endl; }
};

int foo(std::unique_ptr<Students> ptr2)
{
    std::cout << "进入foo函数" << std::endl;
    std::cout << "退出foo函数" << std::endl;
}

int main()
{
    std::cout << "进入main函数" << std::endl;
    //注释段1
    //{
        //std::cout << "进入main函数scope1" << std::endl;
        //auto ptr1 = std::make_unique<Students>();
        //std::cout << "退出main函数scope1" << std::endl;
    //}

    //注释段2
    //auto ptr2 = std::make_unique<Students>();
    //foo(std::move(ptr2));
    std::cout << "退出main函数" << std::endl;
}

打开注释段1,打印如下左边;打开注释段2,打印如下图右边

可以看出 ptr1 的 ownership 属于 block scope,scope代码执行完以后, ptr1 自动执行析构函数;ptr2 的 ownership 一开始属于 main 函数, 通过 std::move 转移给 foo 函数,因此一旦 foo 函数生命周期结束,ptr2 也会自动执行析构函数

看似很完美,然而 unique 的意思是唯一,也就是说这种智能指针的 ownership 只能唯一,因此如下代码,就会崩溃:

void foo(std::unique_ptr<Students> ptr)
{
    ptr->value = 10;
}

void bar(std::unique_ptr<Students> ptr)
{
    ptr->value = 10; //ptr is empty, crash
}

int main()
{
    auto ptr = std::make_unique<Students>();
    foo(std::move(ptr));
    bar(std::move(ptr));
}

原因是什么呢?std::move(ptr) 的意思就是把 ptr 的 ownership 从 main 函数转移给 foo 函数,转移后 ptr 也就没有 ownership了,自动析构了; 针对这种情况,我们就需要用到 shared_ptr , 使用规范如下:

跟 unique_ptr 相比,多了一个use_count。因为它就是用来记录,当前有多少个 ownership,为什么要记录个数呢?因为只有确定它最后一个 ownership 的生命周期结束以后,它才能自动析构。针对上面那个问题,我们用shared_ptr改写:

void foo(const std::shared_ptr<Students> ptr)
{
    puts("enter foo");
    std::cout << ptr.use_count() << std::endl;
    puts("exit foo");
}

int main()
{
    puts("enter main");
    const auto ptr = std::make_shared<Students>();
    std::cout << ptr.use_count() <<std::endl;
	
    {
	puts("enter scope");
	auto ptr_scope1 = ptr;
	std::cout << ptr.use_count() << std::endl;
	auto ptr_scope2 = std::move(ptr_scope1);//transfer ownership
	std::cout << ptr.use_count() << std::endl;
	puts("exit scope");
     }
	
    std::cout << ptr.use_count() << std::endl;
    foo(ptr);
    std::cout << ptr.use_count() << std::endl;
    puts("exit main");
}

打印结果如下:

也许下面这张图,你能理解的更透彻

shared_ptr 的 use_count 的增减做了防 data_race ,因此它默认支持多线程。目前来看,unique_ptr 和 shared_ptr 好像可以解决所有问题,为什么还需要一个 weak_ptr 呢?那么来看下面的情况:

class Students
{
public:
    Students() { puts("Students create"); }
    ~Students() { puts("Students destroy"); }
    std::shared_ptr<Students> next;
    //std::weak_ptr<Students> next;
};

int main()
{
    auto ptr_1 = std::make_shared<Students>();
    auto ptr_2 = std::make_shared<Students>();
    ptr_1->next = ptr_2;
    ptr_2->next = ptr_1;
    std::cout << ptr_1.use_count() << std::endl;
    std::cout << ptr_2.use_count() << std::endl;
}

//print:
//Student create
//Student create
//2
//2

哦豁,并没有打印 Student destroy;也即是说出现了 memory leak;智能指针失灵了?问题就出在赋值的那两句代码导致 use_count = 2,main函数退出后,use_count 仍然为1,因此不会执行析构。因此如果有一种指针,我想用shared_ptr,但又不想它 use_count 有变化,只是在用的时候,检查一下它还在不在,那不就完美了吗?因此,上面的注释打开,就能正确析构。详细用法如下:

    auto ptr = std::make_shared<Students>();
    std::cout << ptr.use_count() << std::endl;
    {
	//必须从 shared_ptr 构造(不会增加 shared_ptr 的 use_count)
	std::weak_ptr<Students> ptr_w = ptr;
		
	//必须从weak_ptr 重新转换成 shared_ptr 再使用
	//ptr的 use_cunt + 1
	std::shared_ptr<Students> ptr_w2s = ptr_w.lock();
	if(ptr_w2s)
	{
	    std::cout << ptr.use_count() << std::endl;
	}
    }	
    std::cout << ptr.use_count() << std::endl;
    //print: 1 2 1

打印结果与预期的结果一致。因此,很多时候,为了避免循环引用,一般shared_ptr 和 weak_ptr 配合使用。官方给出的使用建议是:

  • 尽量使用智能指针替代裸指针
  • 优先使用std::unique_ptr 而不是 shared_ptr (可以想想为什么?)
  • 尽可能 std::move(share_ptr)而不是直接 foo(share_ptr) (理由同上)

内容参考:

  1. B站 花花酱的表世界 Smart Pointers 智能指针 – C++ Weekly EP3
  2. 知乎用户 sin1080 关于 c++ 11 的shared_ptr多线程安全?的优秀回答
  3. C++ ownership semantics