原文出处:虚幻引擎的设计模式与性能优化

本文以设计模式的角度探讨使用虚幻引擎时怎么做性能优化: 空间分区模式、对象池模式、脏标记模式、数据局部性模式、异步加载模式、批处理模式等。

空间分区模式(Spatial Partition)

在游戏开发中,空间分区模式是一种通过有效组织和管理虚拟环境中的游戏对象来优化游戏性能的技术。当需要频繁更新大量对象(例如检查碰撞、在屏幕上渲染对象或执行AI计算)时,此模式尤为有用。

空间分区模式的主要目标是通过将游戏世界划分为更小的、可管理的区域或分区,最大限度地减少不必要的计算和对象之间的比较。这使得游戏引擎能够快速识别哪些对象对于特定操作(如渲染或碰撞检测)是相关的,并忽略其他对象。

在游戏开发中实现空间分区模式有几种技术,包括:

  1. 网格:将游戏世界划分为大小相等的单元格。每个对象都放置在它所占据的单元格中,只需要检查相邻的单元格进行交互。这种方法实现简单,适用于2D游戏或对象分布相对均匀的游戏。

  2. 四叉树(2D)/八叉树(3D):这些数据结构递归地将游戏世界划分为更小的区域,直到达到一定的阈值。树中的每个节点表示一个区域,其子节点表示子区域。对象存储在叶节点中,只需要检查附近的节点进行交互。对于对象分布不均匀的游戏,这种方法更高效,因为它可以适应对象密度。

  3. 二叉空间分割(BSP):这种技术沿着任意平面(通常基于几何形状)递归地划分游戏世界,创建一个二叉树结构。树中的每个节点表示一个区域,其子节点表示分割平面的两侧区域。这种方法通常用于3D游戏,特别是用于渲染和可见性计算。

  4. k-d树:这种数据结构类似于BSP树,但它沿着轴对齐的平面划分游戏世界,在树的每个级别交替使用不同的轴。k-d树通常用于具有大量对象的游戏中进行高效的最近邻搜索和碰撞检测。

  5. R树:这种数据结构专为高效存储和查询边界框形式的空间数据而设计。对于具有不同大小对象的游戏,它尤为有用,因为它可以适应对象的空间分布和大小。

通过在游戏开发中使用空间分区模式,开发人员可以显著提高游戏性能,降低与对象交互和渲染相关的计算开销。这使得游戏世界更加复杂和沉浸,同时为玩家提供更流畅的游戏 体验。

UE4 中的应用例子

  1. 细节层次(LOD):UE4支持细节层次,这是一种根据3D模型与摄像机的距离自动降低模型复杂度的技术。这减少了渲染工作负载,有助于保持高帧率。LOD可以应用于静态网格和骨骼网格。

  2. 剔除:UE4具有内置的剔除技术,例如视锥剔除、遮挡剔除和距离剔除。这些技术通过仅渲染对摄像机可见的对象并忽略其他对象来优化渲染性能。

  3. 分层细节层次(HLOD):HLOD是UE4中的一种优化功能,根据摄像机与对象的距离将多个静态网格合并为一个网格。这减少了绘制调用并提高了渲染性能。HLOD特别适用于具有许多静态对象的大型开放世界环境。

  4. 世界分区:UE在5.0版本中引入了世界分区系统,该系统旨在更有效地处理大型开放世界环境。世界分区根据玩家的位置自动将游戏世界划分为单元格并流式传输必要的数据。这有助于减少内存使用并提高性能。

  5. 碰撞和物理:UE4具有内置的物理引擎(PhysX),该引擎利用空间分区技术(如边界体积层次结构和加速结构)来优化碰撞检测和物理模拟。

  6. AI和导航:UE4的AI和导航系统使用空间分区技术来优化寻路和其他AI相关计算。例如,导航网格被划分为较小的区域,从而实现更高效的寻路和更新。

  7. ReplicationGraph:UE4的ReplicationGraph是一个框架,通过根据角色对玩家的相关性来组织和优先处理角色,从而优化网络复制。它利用空间分区模式(Spatial Partition Pattern)来有效地对不同空间区域中的角色进行分类和管理,减少复制数据的数量。通过使用ReplicationGraph和空间分区模式,UE4确保了多人游戏中的高效网络性能和改进的可扩展性。

代码演示

以下代码片段展示了在虚幻引擎4中实现一个简单的ReplicationGraph。请注意,这仅仅是一个示例,而不是完整的实现。

// MyReplicationGraph.h
#pragma once

#include "CoreMinimal.h"
#include "ReplicationGraph.h"
#include "MyReplicationGraph.generated.h"

UCLASS()
class MYGAME_API UMyReplicationGraph : public UReplicationGraph
{
    GENERATED_BODY()

public:
    UMyReplicationGraph();

    virtual void InitGlobalActorClassSettings() override;
    virtual void InitConnectionGraphNodes(UNetReplicationGraphConnection* ConnectionManager) override;
};

// MyReplicationGraph.cpp
#include "MyReplicationGraph.h"

UMyReplicationGraph::UMyReplicationGraph()
{
    // 设置默认复制设置
    GlobalActorReplicationInfoClass = UBasicReplicationGraph_GlobalInfo::StaticClass();
}

void UMyReplicationGraph::InitGlobalActorClassSettings()
{
    Super::InitGlobalActorClassSettings();

    // 为特定的角色类设置复制周期和距离
    FGlobalActorReplicationInfoClassSettings& MyClassSettings = GlobalActorClassSettingsMap[AMyActor::StaticClass()];
    MyClassSettings.ReplicationPeriodFrame = 2; // 每2帧复制一次
    MyClassSettings.CullDistanceSquared = 10000.0f * 10000.0f; // 10000单位的剔除距离(平方)
}

void UMyReplicationGraph::InitConnectionGraphNodes(UNetReplicationGraphConnection* ConnectionManager)
{
    Super::InitConnectionGraphNodes(ConnectionManager);

    // 添加一个用于优先处理和复制特定角色的节点
    UReplicationGraphNode_ActorList* MyActorNode = CreateNewNode<UReplicationGraphNode_ActorList>();
    ConnectionManager->MyActorNode = MyActorNode;
}

此代码演示了如何创建一个自定义的ReplicationGraph类,为特定的角色类设置复制设置,并添加一个用于优先处理和复制特定角色的自定义节点。在项目中实际实现ReplicationGraph会更复杂,但这个示例应该让您了解它是如何工作的。

对象池模式(Object Pool)

对象池模式是游戏开发中用于优化性能的设计模式,通过重用对象而不是反复创建和销毁它们。这种模式对于资源密集型对象(如游戏实体、粒子系统或复杂的网格渲染器)尤为有用,因为实例化和垃圾收集可能会导致显著的性能开销。

从深入的技术术语来看,对象池模式包括以下组件和过程:

  1. 对象池:这是一个容器或数据结构(例如,列表、队列或堆栈),用于保存一组预先实例化的对象,称为“池项目”。池负责管理这些对象的生命周期,包括它们的分配、重用和释放。

  2. 池项目:这些是由对象池管理的对象。它们在池创建时被创建和初始化,并保持在池中,直到池被销毁。每个池项目都有一个状态(例如,活动或非活动),以表示它当前是否正在使用或可供重用。

  3. 对象分配:当客户端从池中请求一个对象时,池会检查是否有一个非活动的池项目可用。如果有,池将对象返回给客户端,并将其标记为活动状态。如果没有可用的对象,池可能会创建一个新对象(取决于实现)或返回一个空引用,表示客户端必须等待或相应地处理情况。

  4. 对象重用:当客户端完成对池项目的使用时,它将对象返回给池,池将其标记为非活动状态,并使其可供其他客户端重用。这个过程避免了昂贵的对象实例化和垃圾收集,因为对象不会被销毁,而只是返回到池中供将来使用。

  5. 对象释放:当对象池被销毁时(例如,在关卡更改或应用程序关闭期间),所有池项目都会被释放,它们的资源将被释放。

对象池模式在游戏开发中有以下几个优点:

总之,对象池模式是游戏开发中一种有价值的技术,可以实现高效的对象管理,降低性能开销,提高整体游戏性能和稳定性。

UE4 中的应用例子

  1. 粒子系统:UE4的粒子系统Cascade以及更新的Niagara系统都使用池技术来有效管理粒子发射器。它们重用粒子,而不是不断地创建和销毁它们,从而减少了实例化和垃圾收集的开销。

  2. 实例化静态网格和分层实例化静态网格:这些功能允许您使用单个绘制调用渲染静态网格的多个实例,当渲染大量类似对象时显著提高性能。这是一种对象池的形式,因为它重用网格数据,只需要转换实例的位置、旋转和缩放。

  3. AI和角色生成管理:UE4的AI和角色系统可以配置为使用对象池进行高效的NPC或其他游戏实体的生成和消失。这可以通过使用内置的Spawn Actor功能或利用UE4的UObject和Actor类的自定义实现来实现。

  4. 投射物池:在许多游戏中,投射物(如子弹、箭或火箭)经常生成和销毁。UE4允许您使用蓝图或C++创建自定义的投射物池系统,以有效管理这些投射物并减少与它们的创建和销毁相关的性能开销。

  5. 声音和音频组件:UE4的音频系统也可以从池中受益,因为声音和音频组件可以重用,而不是在每次播放时创建和销毁。这可以使用内置的音频组件或自定义池系统来实现。

虽然UE4没有专用的内置对象池系统,但使用蓝图或C++创建自己的对象池系统以有效管理各种游戏对象相对容易。还有社区创建的插件和资源可用,提供对象池功能,可以 集成到您的UE4项目中。

代码演示

以下是一个简单的Unreal Engine 4 C++对象池实现:

  1. 创建一个继承自UObject的新C++类,并将其命名为“ObjectPool”:
// ObjectPool.h
#pragma once

#include "CoreMinimal.h"
#include "UObject/NoExportTypes.h"
#include "ObjectPool.generated.h"

UCLASS(Blueprintable)
class YOURGAME_API UObjectPool : public UObject
{
    GENERATED_BODY()

public:
    UFUNCTION(BlueprintCallable, Category = "Object Pool")
    void InitializePool(TSubclassOf<AActor> ObjectType, int32 PoolSize);

    UFUNCTION(BlueprintCallable, Category = "Object Pool")
    AActor* AcquireObject();

    UFUNCTION(BlueprintCallable, Category = "Object Pool")
    void ReleaseObject(AActor* Object);

private:
    TArray<AActor*> Pool;
    TSubclassOf<AActor> ObjectClass;
};
  1. 在ObjectPool.cpp文件中实现InitializePool、AcquireObject和ReleaseObject函数:
// ObjectPool.cpp
#include "ObjectPool.h"
#include "Engine/World.h"

void UObjectPool::InitializePool(TSubclassOf<AActor> ObjectType, int32 PoolSize)
{
    ObjectClass = ObjectType;

    for (int32 i = 0; i < PoolSize; ++i)
    {
        AActor* NewObject = GetWorld()->SpawnActor<AActor>(ObjectClass, FVector::ZeroVector, FRotator::ZeroRotator);
        NewObject->SetActorHiddenInGame(true);
        NewObject->SetActorEnableCollision(false);
        NewObject->SetActorTickEnabled(false);
        Pool.Add(NewObject);
    }
}

AActor* UObjectPool::AcquireObject()
{
    for (AActor* Object : Pool)
    {
        if (!Object->IsPendingKill() && !Object->IsActorBeingDestroyed() && Object->IsHidden())
        {
            Object->SetActorHiddenInGame(false);
            Object->SetActorEnableCollision(true);
            Object->SetActorTickEnabled(true);
            return Object;
        }
    }

    // 如果没有找到可用对象,返回nullptr
    return nullptr;
}

void UObjectPool::ReleaseObject(AActor* Object)
{
    if (Object)
    {
        Object->SetActorHiddenInGame(true);
        Object->SetActorEnableCollision(false);
        Object->SetActorTickEnabled(false);
    }
}

要在游戏中使用对象池,请按照以下步骤操作:

  1. 创建一个UObjectPool类的实例。
  2. 调用InitializePool函数,用所需的对象类型和池大小初始化池。
  3. 当需要新对象时,调用AcquireObject函数从池中获取可用对象。
  4. 当完成对象的使用后,调用ReleaseObject函数将其返回到池中。

以下是如何在游戏中使用对象池的示例:

// MyGameActor.h
#pragma once

#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "ObjectPool.h"
#include "MyGameActor.generated.h"

UCLASS()
class YOURGAME_API AMyGameActor : public AActor
{
    GENERATED_BODY()

public:
    // 在编辑器中将其设置为所需的对象类型(例如:抛射物、角色等)
    UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Object Pool")
    TSubclassOf<AActor> ObjectType;

    UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Object Pool")
    int32 PoolSize = 10;

protected:
    virtual void BeginPlay() override;

private:
    UPROPERTY()
    UObjectPool* ObjectPool;
};

// MyGameActor.cpp
#include "MyGameActor.h"

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

    ObjectPool = NewObject<UObjectPool>();
    ObjectPool->InitializePool(ObjectType, PoolSize);
}

// 使用示例:从池中获取对象
AActor* NewObject = ObjectPool->AcquireObject();
if (NewObject)
{
    // 设置对象的位置、旋转等,并在游戏中使用它
}

// 使用示例:将对象释放回池中
ObjectPool->ReleaseObject(ObjectToRelease);

此代码演示了一个简单的Unreal Engine 4 C++对象池实现。您可以根据特定的游戏需求进一步定制此实现,例如添加调整大小功能或更详细地处理对象初始化和重置。

脏标记模式(Dirty Flag)

脏标记模式(Dirty Flag pattern)是一种常用于游戏开发和计算机图形编程的设计模式,用于优化应用程序的性能。它在处理复杂、分层或资源密集型系统(如场景图、变换层次结构和渲染管道)时尤为有用。

从技术角度来说,脏标记模式涉及使用一个布尔变量(称为“脏标记”),表示自上次处理或更新某个对象或资源以来,该对象或资源是否已被修改。当对对象进行更改时,该标记设置为“脏”(true),当对象被处理或更新时,设置为“干净”(false)。

脏标记模式的主要目标是最小化冗余或不必要的计算、更新或重绘系统中的对象。通过在执行操作之前检查脏标记,您可以避免重复已完成的工作,或者由于对象未更改而不需要的工作。

以下是关于游戏开发中脏标记模式的更深入的解释:

  1. 场景图:在游戏引擎中,场景图是一种表示游戏对象之间空间关系的分层数据结构。图中的每个节点都可能具有相对于其父节点的变换(位置、旋转、缩放)。当对节点的变换或层次结构进行更改时,可能会影响其所有子节点的全局变换。通过使用脏标记,引擎可以有效地仅更新受影响的节点及其子节点,而不是重新计算整个场景图。

  2. 批处理:在渲染过程中,批处理是将具有相似属性(例如,材质、纹理)的对象分组以最小化渲染场景所需的绘制调用和状态更改的过程。脏标记可用于跟踪对象的属性何时发生更改,从而使引擎仅在必要时更新批处理,减少重建批处理数据的开销。

  3. 缓存:游戏引擎通常使用缓存来存储昂贵计算或资源加载的结果,例如物理模拟、寻路或纹理解压缩。脏标记可用于跟踪这些计算的输入数据何时发生更改,使引擎仅在需要时使缓存失效并进行更新,而不是在每个帧中重新计算数据。

总之,脏标记模式是游戏开发中一种强大的优化技术,通过跟踪系统中对象和资源的更改,有助于最小化冗余工作并提高整体性能。通过使用脏标记,开发人员可以确保只执行必要的更新和计算,从而使应用程序更加高效和响应迅速。

UE4 中的应用例子

  1. 变换层次结构:UE4使用变换层次结构作为其场景图,其中场景中的每个角色都相对于其父角色具有一个变换。当角色的变换发生变化时,它会设置一个脏标记,引擎仅在必要时更新角色及其子级的全局变换,而不是重新计算整个层次结构。

  2. 材质批处理:UE4自动将具有相似材质的对象进行批处理,以减少渲染过程中的绘制调用和状态更改。当对象的材质属性发生变化时,脏标记用于仅在需要时更新批处理。

  3. 蓝图:UE4的可视化脚本系统,称为蓝图(Blueprints),使用基于节点的方法来创建游戏逻辑。当蓝图节点被修改时,会设置一个脏标记,并且引擎仅在必要时重新编译蓝图,避免冗余编译。

  4. 细节层次(LOD):UE4使用细节层次(Level of Detail,LOD)优化网格和纹理,根据距离摄像机的距离自动切换不同的细节层次。当对象的LOD属性发生变化时,脏标记用于更新LOD计算和渲染。

  5. 剔除:UE4使用各种剔除技术,例如视锥剔除和遮挡剔除,以避免渲染摄像机看不见的对象。脏标记用于跟踪对象可见性的更改并相应地更新剔除计算。

  6. PushModel:UE4的推送模型网络是一种优化技术,它仅将必要的数据更新发送给客户端,并利用脏标记模式来标记已修改的属性。通过仅发送变更,它减少了传输的数据量,从而最大限度地减少了处理冗余信息所需的CPU时间。脏标记模式有助于识别需要更新的属性,进一步提高效率并减少CPU使用。

这些仅是UE4在其系统和功能中如何整合脏标记模式的一些例子,以提高性能和资源管理。这种模式在整个引擎中被广泛使用,证明了其在优化游戏开发的各个方面的有效性。

代码演示

以下是如何使用C++在UE4中实现脏标记模式的示例。在此示例中,我们将创建一个具有变换属性和脏标记的简单自定义actor,以跟踪变换中的更改。

1. 首先,创建一个从AActor派生的新C++类,并将其命名为MyCustomActor。在头文件MyCustomActor.h中,添加以下代码:

#pragma once

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

UCLASS()
class MYPROJECT_API AMyCustomActor : public AActor
{
    GENERATED_BODY()

public:
    AMyCustomActor();

    // 设置新的变换并标记脏标记
    void SetTransform(const FTransform& NewTransform);

    // 检查变换是否已更改并更新actor
    void UpdateTransformIfNeeded();

protected:
    virtual void BeginPlay() override;

private:
    // 用于跟踪变换中更改的脏标记
    bool bIsTransformDirty;

    // 新的变换值
    FTransform UpdatedTransform;
};
  1. 接下来,打开MyCustomActor.cpp文件并添加以下代码:
#include "MyCustomActor.h"

AMyCustomActor::AMyCustomActor()
{
    PrimaryActorTick.bCanEverTick = true;
    bIsTransformDirty = false;
}

void AMyCustomActor::BeginPlay()
{
    Super::BeginPlay();
}

void AMyCustomActor::SetTransform(const FTransform& NewTransform)
{
    UpdatedTransform = NewTransform;
    bIsTransformDirty = true;
}

void AMyCustomActor::UpdateTransformIfNeeded()
{
    if (bIsTransformDirty)
    {
        Super::SetActorTransform(UpdatedTransform);
        bIsTransformDirty = false;
    }
}
  1. 在此示例中,我们创建了一个自定义actor,该actor具有一个SetTransform方法,该方法设置新的变换并标记脏标记。UpdateTransformIfNeeded方法检查脏标记是否已设置,并仅在必要时更新actor的变换。

  2. 要在游戏中使用此自定义actor,可以创建一个MyCustomActor实例,并调用SetTransform方法来更新变换。确保在游戏循环中调用UpdateTransformIfNeeded(例如,在Tick方法中),以在需要时应用变换更改。

此示例演示了如何使用C++在UE4中实现脏标记模式的简单实现。您可以根据具体的用例和优化需求,将此模式扩展到其他属性和组件。

数据局部性模式(Data Locality)

在游戏开发中,数据局部性模式是指通过确保数据以连续的内存块存储和处理的方式组织和访问内存中的数据,以最大限度地提高底层硬件(尤其是CPU缓存)的效率。这样可以最小化缓存未命中,并减少从高延迟内存中获取数据所花费的时间。

从深入的技术术语来解释数据局部性模式如下:

  1. 内存层次结构:现代计算机系统具有分层的内存结构,其中CPU缓存速度更快但容量更小,主内存(RAM)速度较慢但容量较大。CPU缓存进一步分为多个级别(L1,L2和L3),每个级别具有不同的访问速度和大小。数据局部性模式的目标是利用这种内存层次结构来提高性能。

  2. 缓存行:数据在CPU缓存和主内存之间以固定大小的块(通常为64字节)传输,称为缓存行。当CPU请求数据时,包含该数据的整个缓存行都会从主内存中获取。数据局部性模式确保一起访问的数据在内存中彼此靠近,增加它们在同一缓存行中的可能性,从而最小化缓存未命中。

  3. 时间和空间局部性:数据局部性模式利用了局部性的两个原则:时间局部性和空间局部性。时间局部性是指程序在短时间内反复访问相同数据的倾向,而空间局部性是指程序访问附近数据的倾向。通过以最大限度地提高时间和空间局部性的方式组织数据,数据局部性模式可以提高缓存利用率和整体性能。

  4. 数据结构和布局:为实现数据局部性,选择支持连续内存访问的适当数据结构和内存布局至关重要。例如,使用数组而不是链表可以提高数据局部性,因为数组将数据存储在连续的内存块中。同样,使用数组的结构(SoA)而不是结构的数组(AoS)可以通过确保只有相关数据一起访问来提高数据局部性,从而减少缓存未命中。

  5. 预取和缓存优化:现代CPU具有硬件预取机制,可以预测将要访问的下一个数据并在需要之前将其提取到缓存中。通过使用数据局部性模式组织数据,您可以帮助硬件预取器更有效地工作。此外,基于软件的缓存优化(如缓存感知和缓存无关算法)可以通过在访问数据时考虑缓存大小和缓存行大小来进一步提高性能。

在游戏开发中,数据局部性模式对于实现高性能至关重要,特别是在性能约束严格的场景中,例如实时渲染、物理模拟和AI。通过以缓存友好的方式组织数据,开发人员可以最小化缓存未命中,减少内存访问延迟,并确保CPU花费更多时间处理数据,而不是等待从内存中获取数据。

UE4 中的应用例子

  1. 游戏玩法能力系统(Game Ability System):UE4的游戏玩法能力系统旨在通过以缓存友好的方式组织数据来最大限度地提高数据局部性。GAS 将数据(组件)与逻辑(系统)分离,允许数据以连续的方式存储和访问。游戏玩法能力系统还利用面向数据的设计原则来确保高效的内存访问模式。

  2. 尼亚加拉粒子系统:UE4中的尼亚加拉粒子系统采用面向数据的方法来优化数据局部性。它将粒子数据存储在连续的内存块中,并以SIMD(单指令,多数据)友好的方式处理它们,确保高效利用CPU缓存并提高性能。

  3. 实例静态网格和分层实例静态网格:UE4的实例静态网格和分层实例静态网格旨在通过减少绘制调用和提高数据局部性来优化渲染性能。它们将相同网格的实例存储在连续的内存中,使GPU能够更高效地处理它们。

  4. 细节层次(LOD)和Impostors:UE4使用LOD和Impostor技术通过根据物体与摄像机的距离减少物体的复杂性来优化渲染性能。这有助于提高数据局部性,因为需要从内存中获取的顶点和纹理较少,从而减少缓存未命中。

  5. 内存管理和垃圾回收:UE4具有强大的内存管理系统,在分配和释放内存时考虑数据局部性。它还包括一个垃圾回收系统,有助于减少内存碎片,确保数据连续存储并高效访问。

这些只是UE4在其系统和功能中遵循数据局部性模式的一些例子。通过以缓存友好的方式组织数据并优化内存访问模式,UE4可以在游戏开发的各个方面(包括渲染、物理模拟和AI)实现高性能。

代码演示

可以在类似于 Tick Manager 的系统中实现数据局部性模式,我们可以将其称为“Tick Assembly”。这个想法是拥有一个集中管理多个对象的 tick 的系统,确保以缓存友好的方式访问数据。

以下是在 UE4 中创建一个简单的 Tick Assembly 系统的示例:

  1. 首先,为可以由 Tick Assembly 执行的对象定义一个接口:
class ITickable
{
public:
    virtual void Tick(float DeltaTime) = 0;
};
  1. 创建一个管理可 tick 对象的 Tick Assembly 类:
class FTickAssembly
{
public:
    void AddTickable(ITickable* Tickable)
    {
        Tickables.Add(Tickable);
    }

    void RemoveTickable(ITickable* Tickable)
    {
        Tickables.Remove(Tickable);
    }

    void Tick(float DeltaTime)
    {
        for (ITickable* Tickable : Tickables)
        {
            Tickable->Tick(DeltaTime);
        }
    }

private:
    TArray<ITickable*> Tickables;
};
  1. 修改前面示例中的 AMyParticleActor 类以实现 ITickable 接口:
UCLASS()
class MYGAME_API AMyParticleActor : public AActor, public ITickable
{
    GENERATED_BODY()
public:
    AMyParticleActor();
    virtual void Tick(float DeltaTime) override;
private:
    FParticleSystem ParticleSystem;
};
void AMyParticleActor::Tick(float DeltaTime)
{
    UpdateParticleSystem(ParticleSystem, DeltaTime);
}
  1. 创建一个管理 Tick Assembly 的自定义游戏模式类:
UCLASS()
class MYGAME_API AMyGameMode : public AGameModeBase
{
    GENERATED_BODY()

public:
    AMyGameMode();

    virtual void Tick(float DeltaTime) override;

    void RegisterTickable(ITickable* Tickable);
    void UnregisterTickable(ITickable* Tickable);

private:
    FTickAssembly TickAssembly;
};
AMyGameMode::AMyGameMode()
{
    PrimaryActorTick.bCanEverTick = true;
}

void AMyGameMode::Tick(float DeltaTime)
{
    Super::Tick(DeltaTime);

    TickAssembly.Tick(DeltaTime);
}

void AMyGameMode::RegisterTickable(ITickable* Tickable)
{
    TickAssembly.AddTickable(Tickable);
}

void AMyGameMode::UnregisterTickable(ITickable* Tickable)
{
    TickAssembly.RemoveTickable(Tickable);
}
  1. 更新 AMyParticleActor 类以向 Tick Assembly 注册和注销自身:
AMyParticleActor::AMyParticleActor()
{
    PrimaryActorTick.bCanEverTick = false;

    // Initialize the particle system with some particles
    // ...
}

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

    AMyGameMode* MyGameMode = Cast<AMyGameMode>(GetWorld()->GetAuthGameMode());
    if (MyGameMode)
    {
        MyGameMode->RegisterTickable(this);
    }
}

void AMyParticleActor::EndPlay(const EEndPlayReason::Type EndPlayReason)
{
    AMyGameMode* MyGameMode = Cast<AMyGameMode>(GetWorld()->GetAuthGameMode());
    if (MyGameMode)
    {
        MyGameMode->UnregisterTickable(this);
    }

    Super::EndPlay(EndPlayReason);
}

在这个示例中,我们创建了一个 Tick Assembly 系统,以集中方式管理多个对象(在本例中为 AMyParticleActor 实例)的 tick。这种方法可以通过确保由可 tick 对象访问的数据以缓存友好的方式访问来提高数据局部性。然而,需要注意的是,这个示例相当简单,更复杂的场景可能需要额外的优化以确保最大的缓存效率。

异步加载模式(Asynchronous Loading)

在游戏开发中,异步加载模式是指一种与游戏执行同时进行的加载游戏资源(如纹理、模型、声音或关卡数据)的技术,而不会导致游戏过程中出现明显的延迟或中断。这种模式对于维护流畅且无缝的游戏体验至关重要,特别是在大型开放世界游戏或资源加载较多的游戏中。

从深入的技术术语来解释,异步加载模式可以解释为以下几点:

  1. 多线程:异步加载依赖于多线程的概念,即在单个进程中同时运行多个执行线程。在游戏开发的背景下,一个线程(主线程)负责处理游戏逻辑、渲染和用户输入,而一个或多个其他线程(工作线程)负责在后台加载资源。

  2. 任务队列:主线程维护一个任务队列,这是一个包含需要加载的资源列表的数据结构。随着游戏的进行以及需要加载新资源,主线程将任务添加到队列中。工作线程不断检查任务队列以查找新任务以进行处理。一旦任务完成,工作线程将其从队列中删除并通知主线程。

  3. 非阻塞 I/O:异步加载需要使用非阻塞 I/O 操作从磁盘读取资源数据。这意味着工作线程可以启动 I/O 操作并在等待 I/O 操作完成之前继续执行其他任务。当 I/O 操作完成时,工作线程可以处理加载的数据并相应地更新游戏状态。

  4. 双缓冲:为确保主线程始终可以访问最新的资源数据,可以采用双缓冲技术。这涉及为每个资源维护两个单独的缓冲区:一个供主线程访问,另一个供工作线程更新。当工作线程完成资源加载时,它会更新辅助缓冲区,然后通知主线程交换缓冲区。

  5. 同步:异步加载需要在主线程和工作线程之间进行仔细同步,以防止竞态条件和其他并发相关问题。这可以通过使用各种同步原语(如互斥锁、信号量或条件变量)来实现,以确保以安全且受控的方式访问共享资源。

  6. 渐进式加载:在某些情况下,资源可以逐步加载,这意味着它们是按块或细节级别加载的,使游戏在仍在加载更高质量版本的资源时显示较低质量的资源版本。这有助于最大程度地减少加载时间的视觉影响,并为玩家提供更无缝的体验。

总之,游戏开发中的异步加载模式是一种允许在不中断游戏过程的情况下同时加载游戏资源的技术。这是通过使用多线程、非阻塞I/O、任务队列、双缓冲、同步和渐进式加载来实现的。

UE4 中的应用例子

  1. 异步资源加载:UE4 提供了在运行时使用蓝图中的 Async Load 节点或 C++ 中的 FStreamableManager 类异步加载资源的功能。这些方法允许您在后台加载资源,而不会阻塞游戏的主线程,确保流畅的游戏体验。

  2. 关卡流式加载:关卡流式加载是 UE4 的一个功能,允许您在运行时异步加载和卸载子关卡(也称为流式关卡)。这对于开放世界游戏或关卡较大的游戏特别有用,因为它确保只将关卡的必要部分加载到内存中,从而减少内存使用并提高性能。

  3. 多线程动画:UE4 的动画系统支持多线程,允许动画更新在与主游戏线程分开的线程上运行。这有助于将一些处理开销从主线程卸载,提高整体性能。

  4. 纹理流式加载:UE4 的纹理流式加载系统根据纹理的可见性和与摄像机的距离动态加载和卸载纹理。这确保只将必要的纹理加载到内存中,减少内存使用并提高性能。纹理流式加载系统还支持异步加载,允许在后台加载纹理而不会导致游戏卡顿。

  5. 音频流式加载:UE4 的音频系统支持长音频文件(如背景音乐或对话)的流式加载,以减少内存使用并提高性能。音频文件可以异步流式加载,允许它们在后台加载而不会阻塞主游戏线程。

UE4 中的这些内置系统和功能遵循异步加载模式,通过在后台加载资源和数据而不会导致中断或性能问题,帮助确保流畅且无缝的游戏体验。

代码演示

以下是一个简单的代码示例,说明了如何在 UE4 中使用 C++ 进行异步资源加载。在此示例中,我们将异步加载一个静态网格资源,并在加载完成后将其生成到游戏世界中。

// 头文件(MyAsyncLoader.h)
#pragma once

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

UCLASS()
class MYPROJECT_API AMyAsyncLoader : public AActor
{
    GENERATED_BODY()

public:
    AMyAsyncLoader();
    virtual void BeginPlay() override;

private:
    UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Async Loading", meta = (AllowPrivateAccess = "true"))
    TSoftObjectPtr<UStaticMesh> MyStaticMeshAsset;

    void OnAssetLoaded();
};

// 源文件(MyAsyncLoader.cpp)
#include "MyAsyncLoader.h"
#include "Components/StaticMeshComponent.h"
#include "Engine/StreamableManager.h"

AMyAsyncLoader::AMyAsyncLoader()
{
    PrimaryActorTick.bCanEverTick = false;
    MyStaticMeshAsset = TSoftObjectPtr<UStaticMesh>(FSoftObjectPath(TEXT("StaticMesh'/Game/MyStaticMesh.MyStaticMesh'")));
}

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

    // 检查资源是否已加载
    if (MyStaticMeshAsset.IsPending())
    {
        // 设置一个委托,在资源加载时调用
        FStreamableDelegate OnAssetLoadedDelegate;
        OnAssetLoadedDelegate.BindUObject(this, &AMyAsyncLoader::OnAssetLoaded);

        // 请求异步加载资源
        UStreamableManager& StreamableManager = UStreamableManager::Get();
        StreamableManager.RequestAsyncLoad(MyStaticMeshAsset.ToSoftObjectPath(), OnAssetLoadedDelegate);
    }
    else
    {
        // 如果资源已经加载,直接调用 OnAssetLoaded 函数
        OnAssetLoaded();
    }
}

void AMyAsyncLoader::OnAssetLoaded()
{
    // 检查资源是否已加载
    if (MyStaticMeshAsset.IsValid())
    {
        // 在游戏世界中生成静态网格
        UStaticMeshComponent* StaticMeshComponent = NewObject<UStaticMeshComponent>(this);
        StaticMeshComponent->SetStaticMesh(MyStaticMeshAsset.Get());
        StaticMeshComponent->RegisterComponent();
        StaticMeshComponent->AttachToComponent(RootComponent, FAttachmentTransformRules::KeepRelativeTransform);
    }
}

在此示例中,我们使用 TSoftObjectPtr 存储对静态网格资源的引用。然后我们使用 UStreamableManager 类请求异步加载资源。当资源加载时,将调用 OnAssetLoaded 函数,该函数将静态网格生成到游戏世界中。

请注意,此示例假定您有一个位于 "/Game/MyStaticMesh.MyStaticMesh" 的静态网格资源。确保将资源路径替换为指向所需资源的适当路径。

批处理模式(Batching)

批处理是游戏开发中用于减少在屏幕上渲染多个对象所产生的开销的性能优化技术。它涉及将具有相似属性(如纹理、着色器或材质)的对象分组在一起,然后在单个绘制调用中渲染它们。这样可以将 GPU 所需的状态更改降至最低,从而显著提高渲染性能。

从深入的技术术语来解释批处理模式:

  1. 排序和分组:在第一步中,根据对象的属性(如纹理、材质、着色器或其他渲染属性)对游戏对象进行排序和分组。这个过程有助于在渲染过程中最小化状态更改。

  2. 生成顶点和索引缓冲区:一旦对象被分组,为每个批次生成顶点和索引缓冲区。顶点缓冲区是一个数组,包含批次中所有对象的顶点数据(如位置、颜色、纹理坐标和法线)。索引缓冲区是一个索引数组,定义了顶点绘制的顺序。

  3. 合并几何体:批处理通常涉及将多个对象的几何体合并为一个网格。这可以通过合并批次中每个对象的顶点和索引来完成,并注意更新索引缓冲区以适应新的顶点位置。

  4. 绘制调用:在生成并合并顶点和索引缓冲区后,发出单个绘制调用以渲染整个批次。这比为每个对象发出单独的绘制调用更有效,因为它将渲染所需的 GPU 管道设置开销降至最低。

  5. 动态批处理:在某些情况下,对象可能在运行时被添加或删除,或者它们的属性可能发生变化。动态批处理是一种允许根据这些更改有效更新批次的技术。这通常涉及根据需要重新排序和重新分组对象,以及更新顶点和索引缓冲区以反映场景的新状态。

  6. 实例化:与批处理相关的另一种技术是实例化,它涉及使用单个绘制调用渲染同一对象的多个实例。这对于共享相同几何体的对象(如树或人群中的角色)特别有用。实例化可以与批处理相结合,以进一步优化渲染性能。

总之,游戏开发中的批处理模式是通过最小化状态更改和绘制调用来优化渲染性能的关键技术。它涉及将具有相似属性的对象分组,生成和合并顶点和索引缓冲区,并发出单个绘制调用以渲染整个批次。这种方法可以显著提高游戏的帧速率和整体性能,特别是在渲染大量对象时。

UE4 中的应用例子

UE4提供了一些内置的高级技术,遵循批处理模式以优化渲染性能。这些技术包括:

  1. 分层实例化静态网格(Hierarchical Instanced Static Meshes,HISM):这是实例化静态网格的扩展,允许您在树形层次结构中组织实例。这可以在渲染大量实例时提高性能,因为它使UE4能够更有效地执行视锥裁剪和遮挡裁剪。要使用HISM,只需在之前提供的代码示例中将UInstancedStaticMeshComponent替换为UHierarchicalInstancedStaticMeshComponent。

  2. 网格距离场(Mesh Distance Fields):这是一种用于各种渲染优化的技术,如遮挡剔除、动态阴影和全局光照。通过在项目设置中启用网格距离场,UE4可以自动为您的静态网格生成距离场,并使用它们执行更高效的剔除和渲染。

  3. 自动细节层次(LOD)和分层细节层次(HLOD):这些技术涉及创建简化版本的3D模型(LOD),并根据相机与对象的距离在它们之间切换。这可以显著减少需要渲染的多边形数量,提高性能。HLOD进一步将多个对象合并为一个网格,进一步减少绘制调用。

  4. 冒牌精灵(Impostor Sprites):这种技术涉及将远离摄像机的3D对象渲染为2D精灵。这可以大大降低远距离对象的渲染复杂性,提高性能。UE4通过使用Simplygon插件提供了对冒牌精灵的内置支持。

  5. GPU实例化:这是一种硬件级优化技术,允许GPU在单个绘制调用中渲染相同网格的多个实例,进一步降低渲染大量对象的开销。UE4会在可能的情况下自动使用GPU实例化,因此您不需要手动启用它。

这些只是UE4提供的遵循批处理模式的高级技术的一些例子。通过在项目中使用这些技术,您可以显著提高渲染性能,即使在处理复杂场景和大量对象时也能保持高帧速率。

代码演示

以下是一个简单的示例,说明如何在UE4中使用C++创建和使用实例化静态网格:

  1. 首先,在您的C++类中包含必要的头文件:
#include "Components/InstancedStaticMeshComponent.h"
#include "Engine/StaticMesh.h"
  1. 在您的类中创建一个UInstancedStaticMeshComponent:
UInstancedStaticMeshComponent* InstancedMeshComponent;
  1. 在构造函数中初始化实例化静态网格组件:
// 构造函数
AMyActor::AMyActor()
{
    // 设置此actor每帧调用Tick()
    PrimaryActorTick.bCanEverTick = true;

    // 创建实例化静态网格组件
    InstancedMeshComponent = CreateDefaultSubobject<UInstancedStaticMeshComponent>(TEXT("InstancedMeshComponent"));
    RootComponent = InstancedMeshComponent;

    // 为实例化静态网格组件设置静态网格
    static ConstructorHelpers::FObjectFinder<UStaticMesh> MeshAsset(TEXT("/Game/Path/To/Your/StaticMesh"));
    if (MeshAsset.Succeeded())
    {
        InstancedMeshComponent->SetStaticMesh(MeshAsset.Object);
    }
}
  1. 在不同的位置和旋转角度添加静态网格实例:
// 在不同的位置和旋转角度添加静态网格实例
FTransform InstanceTransform;
InstanceTransform.SetLocation(FVector(0.f, 0.f, 0.f));
InstanceTransform.SetRotation(FQuat(FRotator(0.f, 0.f, 0.f)));
InstancedMeshComponent->AddInstance(InstanceTransform);

InstanceTransform.SetLocation(FVector(100.f, 0.f, 0.f));
InstanceTransform.SetRotation(FQuat(FRotator(0.f, 90.f, 0.f)));
InstancedMeshComponent->AddInstance(InstanceTransform);

InstanceTransform.SetLocation(FVector(200.f, 0.f, 0.f));
InstanceTransform.SetRotation(FQuat(FRotator(0.f, 180.f, 0.f)));
InstancedMeshComponent->AddInstance(InstanceTransform);

在这个例子中,我们创建了一个实例化静态网格组件,为其设置了一个静态网格,然后在不同的位置和旋转角度添加了三个静态网格实例。这些实例将在一个绘制调用中渲染,这展示了UE4中的批处理技术。

请注意,这是一个简单的示例,UE4中可以使用更高级的技术,如分层实例化静态网格或动态批处理,以获得更好的性能。

享元模式(Flyweight)

在游戏开发中,享元模式是一种用于最小化内存使用和优化性能的结构设计模式,通过共享和重用相似对象实现。当处理具有一些共享特征的大量对象时,此模式尤其有用。

从深入的技术术语来看,享元模式涉及以下组件:

  1. 享元:这是一个接口或抽象类,为将共享的对象定义通用方法和属性。享元对象通常是不可变的,这意味着在创建后无法更改其内部状态。

  2. 具体享元:这个类实现了享元接口,代表共享的对象。它存储对象的内在状态(共同特征)并提供操作它的方法。

  3. 享元工厂:这个类负责创建和管理享元对象。它维护一个现有享元的池(通常是哈希映射或字典),并确保仅在池中不存在新对象时才创建新对象。如果请求现有对象,工厂将返回对该对象的引用。

  4. 客户端:这是与享元工厂交互以请求和使用共享对象的类。客户端负责维护对象的外部状态(独特特征)并在需要时将其传递给享元。

在游戏开发中,享元模式可以应用于各种场景,例如:

  1. 纹理和精灵管理:与加载多个相同纹理或精灵的实例相反,可以在多个游戏对象之间共享单个实例,从而减少内存使用并提高性能。

  2. 粒子系统:在粒子系统中,具有相似属性(如颜色、形状和行为)的数千个粒子可以由单个享元对象表示,使系统更加高效。

  3. 地形和环境:大型游戏世界通常包含重复元素,如树、岩石和建筑物。使用享元模式,这些元素可以重用,节省内存并加快渲染速度。

  4. 人工智能和寻路:在具有众多AI控制角色的游戏中,它们的共享行为逻辑和寻路算法可以使用享元模式实现,以减少冗余和提高性能。

总之,享元模式是游戏开发中的一种强大技术,通过共享和重用相似对象来帮助最小化内存使用和优化性能。通过分离内在和外在状态,它允许高效管理具有共同特征的大量对象。

UE4 中的应用例子

  1. 实例化静态网格:UE4允许使用实例化静态网格,通过单个绘制调用渲染相同静态网格的多个实例。这种技术通过在所有实例之间共享网格数据和材质来遵循享元模式,从而减少内存使用并提高渲染性能。

  2. 分层细节级别(HLOD):HLOD是UE4中用于优化大型复杂场景渲染的技术。它将多个静态网格合并为一个网格,共享相同的材质和纹理,从而减少绘制调用次数和内存使用。这种方法通过在合并的网格之间共享内在状态(网格数据和材质)遵循享元模式。

  3. 材质实例:UE4提供了材质实例,这是一种与父材质共享相同着色器和纹理数据的轻量级版本。材质实例允许快速更改材质属性,如颜色或粗糙度,无需创建全新的材质。这通过在所有材质实例之间共享内在状态(着色器和纹理数据)遵循享元模式。

  4. 粒子系统:UE4的粒子系统(Cascade)也可以通过在多个粒子系统之间共享相同的粒子发射器属性、材质和纹理来实现享元模式。这减少了内存使用和渲染开销,尤其是在处理大量粒子时。

UE4中的这些内置技术展示了如何在游戏引擎中有效地使用享元模式来优化内存使用和提高性能。通过共享和重用相似对象及其内在状态,UE4可以高效地管理大量对象和复 杂场景。

代码演示

在UE4中,可以使用材质实例来实现享元模式,材质实例是与父材质共享相同着色器和纹理数据的轻量级版本。以下是一个如何使用C++和蓝图在UE4中创建和使用材质实例的简单示例:

  1. 在内容浏览器中创建一个新的材质。将其命名为“BaseMaterial”并打开它。

  2. 在材质编辑器中,创建一个简单的材质设置,例如,基本颜色和粗糙度。要使这些属性可自定义,请添加两个标量参数节点(名为“BaseColor”和“Roughness”),并将它们连接到材质的适当输入。

  3. 保存并关闭材质编辑器。

现在,让我们使用C++和蓝图创建材质实例:

C++:

在您的C++类中(例如,自定义Actor或Component),您可以创建并设置材质实例,如下所示:

#include "Materials/MaterialInstanceDynamic.h"

// ...

// 从内容浏览器加载基本材质
static ConstructorHelpers::FObjectFinder<UMaterial> BaseMaterial(TEXT("Material'/Game/BaseMaterial.BaseMaterial'"));
if (BaseMaterial.Succeeded())
{
    // 创建一个动态材质实例
    UMaterialInstanceDynamic* MaterialInstance = UMaterialInstanceDynamic::Create(BaseMaterial.Object, this);

    // 设置参数值
    MaterialInstance->SetScalarParameterValue("BaseColor", 0.5f);
    MaterialInstance->SetScalarParameterValue("Roughness", 0.8f);

    // 将材质实例分配给网格组件
    MeshComponent->SetMaterial(0, MaterialInstance);
}

蓝图:

  1. 在内容浏览器中,右键单击“BaseMaterial”并选择“创建材质实例”。将其命名为“MaterialInstance_BP”。

  2. 打开“MaterialInstance_BP”,并根据需要设置“BaseColor”和“Roughness”的参数值。

  3. 在您的蓝图类中(例如,自定义Actor或Component),添加一个网格组件并将“MaterialInstance_BP”分配给它。

此示例演示了如何在UE4中使用享元模式在多个实例之间共享材质的内在状态(着色器和纹理数据)。通过使用材质实例,您可以创建具有不同属性的材质变体,同时最小化内存使用并提高性能。

<未完待续>

参考文献