游戏中的虚拟世界是如何组织的呢?
这里,在玩家中流行使用一个词——关卡。玩家们进入关卡,探索关卡内的迷宫,击败关底boss,然后进入下一个关卡,周而复始。
在Unity3D里,这个关卡被称为是Scene,作为游戏内的一项项单独的资产而存在。这些Scene可以单独存在,也可以和其他Scene组合使用(多场景加载),同时,Scene和Scene之间又可以用SceneManager来做跳转(加载和卸载)。
在unreal engine里对于关卡的使用更加直白,就直接是一个个的Level,同样对于关卡本身所应该具备的逻辑操作(关卡的游玩,关卡的加载和卸载),虚幻也实现了一套架构来承载。
但是什么是World呢?单单用“世界”一词似乎太过简单,我们在游戏开发里做的一切——音乐、渲染、物理规则,这些所有都是为了让玩家感受到另一个不同于现实世界的世界。可以说玩家将在游戏内体会到的一切都应该是属于这个World的范畴内的。
虚幻本身也基本上是以这种思路来安排World的角色的,零散的Level将玩家的完整体验切割成段,所有的Level流程组合起来才成了对World的完整体验。(将World切割成一个个的Level的原因之一也是前一个时代机器性能不足而做出妥协,我们没办法把一整个游戏的所有东西全部一下子加载进来,带宽也好,内存也好。现如今出现了越来越多的开放世界游戏,正是业内的能工巧匠们利用硬件的进步和自己的各种奇思妙想来越来越妥善得解决这个问题)
在虚幻引擎内,Level是作为一项资产而存在,也就是后缀为.umap
的文件。也就是说,Level支持所有文件一级的操作——创建,保存,打开等等。
这一小节快速的罗列一下Level(或者说.umap
文件)的基本操作和用法。
快捷键:Ctrl + N
键入快捷键命令后,调出如下的窗口,ue5中给出了一些默认的关卡模板,这些模板(除了Empty Level)会提供一些关卡所需的基本内容,如基本光照环境,基本的天空球等等。
选定模板后,点击创建即可。此时编辑器会最懂打开并加载选定的模板。
但是注意,此时该Level还没有被保存,资产面板中也还没有出现对应的.umap
文件。
快捷键:Ctrl + S
键入快捷命令后,调出如下窗口,这一步就是要选定.umap
文件的命名和保存位置。
此外,在切换当前编辑器显示的Level时,编辑器也会弹出窗口提示Level的相关保存事宜。
快捷键:Ctrl + O
键入快捷命令后,调出如下窗口,其实就是相当于加了Level过滤器的资产界面,只会显示Level类型的内容。
当然,直接在资产选择界面也可以选定相应的Level资产并且双击打开。
我们的游戏打开时会加载哪个Level呢?总不能什么都不加载空荡荡吧?
相关的选项可以在Project Settings -> Maps & Modes -> Default Maps来进行配置。
那么既然现在已经可以创建一个个的Level了,那么自然而然就会想到去做Level间的跳转,即我们游戏的主角结束一个Level的游玩后,通过某种装置或者传送门进入到另外的一个Level,这个跳转往往会需要一定的时间,甚至于某些情况下需要做一些跳转过渡画面来等候。
最简单的方案,可以使用GameplayStatics提供的了一个静态方法OpenLevel,可以用于进行关卡的打开。
可以利用TriggerBox制作一个简单的触发机制,等到玩家角色进入范围时进行关卡的跳转。
方法的核心部分方法的核心部分是GEngine的SetClientTravel,目前为止现在了解到这一层就足够了。
void UGameplayStatics::OpenLevel(const UObject* WorldContextObject, FName LevelName, bool bAbsolute, FString Options)
{
...
GEngine->SetClientTravel( World, *Cmd, TravelType );
}
从虚幻编辑器内的安排上我们似乎并不能够很好的看清World的存在,与之相关联的最直接的好像有一位World Setting的存在,那我们就先来看看World Setting里有什么内容。
World Setting面板的打开路径是Window->World Setting,其粗略内容如下:
从World Setting里的配置内容来看,World Setting更像是描述单个的Level的设置(而不是对整个World的配置),即如果将之成为是Level Setting也不过分。
事实上也的确如此,其背后原因从代码层面可以一窥端倪。
ULevel的部分核心属性:
// ULevel本身直接继承于UObject,所以本身也具备垃圾回收,反射,支持序列化,等等
//
// The level object. Contains the level's actor list, BSP information, and brush list.
// Every Level has a World as its Outer and can be used as the PersistentLevel, however,
// when a Level has been streamed in the OwningWorld represents the World that it is a part of.
//
/**
* A Level is a collection of Actors (lights, volumes, mesh instances etc.).
* Multiple Levels can be loaded and unloaded into the World to create a streaming experience.
*/
UCLASS(MinimalAPI)
class ULevel : public UObject, public IInterface_AssetUserData, public ITextureStreamingContainer
{
...
public:
// 前面提到了GameStatics里的OpenLevel方法,该方法的参数虽然是关卡名,
// 但实际底层的SetClientTravel函数的接收的确是一个FURL类型的参数,而ULevel也维护着这样一个变量
/** URL associated with this level. */
FURL URL;
// 关卡本身算是Actor们的载体(集合),这里用一个容器来容纳所有的Actor元素
/** Array of all actors in this level, used by FActorIteratorBase and derived classes */
TArray Actors;
...
// 关卡也存着一枚指向所在世界的指针
/**
* The World that has this level in its Levels array.
* This is not the same as GetOuter(), because GetOuter() for a streaming level is a vestigial world that is not used.
* It should not be accessed during BeginDestroy(), just like any other UObject references, since GC may occur in any order.
*/
UPROPERTY(Transient)
TObjectPtr OwningWorld;
...
// 关卡蓝图(level blueprint),也对应着我们可以在编辑器内打开的那个关卡蓝图,
// 它本身是一个ALevelScriptActor,继承自Actor,所以拥有着Actor的大部分特性(包括接收输入等)
// 自然地,既然是Actor,LevelScriptActor也包含在上面Actors数组里
/** The level scripting actor, created by instantiating the class from LevelScriptBlueprint. This handles all level scripting */
UPROPERTY(NonTransactional)
TObjectPtr LevelScriptActor;
...
private:
// 关卡中保存着WorldSettings的一枚指针,正是前面编辑器中的那个World Setting
// 自然地,既然是Actor,WorldSettings也包含在上面Actors数组里
UPROPERTY()
TObjectPtr WorldSettings;
...
}
Actor是我们已经比较熟悉的类,现在我们知道LevelBlueprint本身也是Actor,这也是属于“不可见的”Actor之一。
而LevelScriptActor直接继承自Actor,当然在Actor基础上,做了一定的补充和修改,比如说关于渲染和碰撞等,但是又允许继承自Actor的输入相关的接收等,我们在蓝图中使用时基本上就按Actor的理解来写即可。
WorldSetting则是继承自AInfo(也就是常见的允许存在于),不同于那些可以拖进编辑器场景中的,AInfo就是那类为场景做贡献但是不需要进入到场景中的Actor。
这一部分代码的阅读也着重参考了大钊的文章(参考文底连接):
除了其他零零碎碎的成员,还有一个比较关键的就是OwningWorld指针,它指向该关卡所属的UWorld对象。
UWorld的部分核心属性:
// 相较于Level,World更像是一个管理者,Level的管理者
// Level在它上面可以自由组合,可以流式加载,从而作为更庞大的世界的基础
// 而且最重要的,World同样可以不只有一个,游戏的世界,编辑器的世界,PIE实例世界等等
//
/**
* The World is the top level object representing a map or a sandbox in which Actors and Components will exist and be rendered.
*
* A World can be a single Persistent Level with an optional list of streaming levels that are loaded and unloaded via volumes and blueprint functions
* or it can be a collection of levels organized with a World Composition.
*
* In a standalone game, generally only a single World exists except during seamless area transitions when both a destination and current world exists.
* In the editor many Worlds exist: The level being edited, each PIE instance, each editor tool which has an interactive rendered viewport, and many more.
*
*/
UCLASS(customConstructor, config=Engine)
class ENGINE_API UWorld final : public UObject, public FNetworkNotify
{
// PersistantLevel将是我们后面一个小节的核心内容之一,也可以看作所谓的主关卡
// 其主要作用体现在关卡的流式管理(Level Streaming)中
// 它本身的话代表了当前世界中的核心关卡(代表着当多关卡之间的设置有冲突时,优先以PersistantLevel为依据)
/** Persistent level containing the world info, default brush and actors spawned during gameplay among other things */
UPROPERTY(Transient)
TObjectPtr PersistentLevel;
...
// 相对于PersistantLevel,就会有StreamingLevels,表示那些动态加载和卸载的关卡们
// PersistantLevel只有一个,而StreamingLevels的数量就很灵活了
/** Level collection. ULevels are referenced by FName (Package name) to avoid serialized references. Also contains offsets in world units */
UPROPERTY(Transient)
TArray> StreamingLevels;
...
// 世界的类型
/** The type of world this is. Describes the context in which it is being used (Editor, Game, Preview etc.) */
TEnumAsByte WorldType;
...
// 当前的GameMode,当有多个关卡加载到世界中时,对应也会有多个World Setting
// 那么以哪个配置为准呢,这里负责维护着对应的指针
/** The current GameMode, valid only on the server */
UPROPERTY(Transient)
TObjectPtr AuthorityGameMode;
...
// 同上
/** The replicated actor which contains game state information that can be accessible to clients. Direct access is not allowed, use GetGameState<>() */
UPROPERTY(Transient)
TObjectPtr GameState;
...
// 世界就是关卡的集合,这里就是关卡们组成的数组
/** Array of levels currently in this world. Not serialized to disk to avoid hard references. */
UPROPERTY(Transient)
TArray> Levels;
...
// PersistentLevel和CurrentLevel只是个快速引用。
// 在编辑器里编辑的时候,CurrentLevel可以指向其他Level,但运行时CurrentLevel只能是指向PersistentLevel。
/** Pointer to the current level being edited. Level has to be in the Levels array and == PersistentLevel in the game. */
UPROPERTY(Transient)
TObjectPtr CurrentLevel;
...
// 该World所从属的GameInstance
UPROPERTY(Transient)
TObjectPtr OwningGameInstance;
这部分代码的阅读同样参考了大钊的文章:
本文从使用层面总结了Level的基础用法,从编辑器层面罗列了Level和World相关的配置选项,还从代码层面了解几个核心类(Level,World等)之间的架构关系。
其中尤其,从Level和World的代码中,可以清晰得看到Level是如何承载Actor的(包括一些看不见的Actor),看到World的组成又是怎样的。当然虚幻中这两者的应用远不止于此,这篇文章仅仅算是将Level和World相关内容破了个题,相关的内容还包括World是如何组织Level的——Level Streaming相关内容,大世界相关的优化问题——World Composition和World Partition相关的配置,等等。
本文更多的是对几个基本概念进行拆解,对代码结构有一个基本的认识,在此基础之上,后续还会再跟进Level Streaming、World Composition和World Partition相关的内容和文章。
虚幻 5.0 Documentation - Levels
知乎作者 大钊 的文章《InsideUE4 GamePlay架构(二)Level和World》