0%

虚幻蓝图

10.28虚幻蓝图(11.1补充)

UBlueprint

pA0BAL8.png https://zhuanlan.zhihu.com/p/450520990

UBlueprintCore中的核心内容

  • TSubclassOf< class UObject> SkeletonGeneratedClass;
  • TSubclassOf< class UObject> GeneratedClass;

TSubclassOf意思是模板的UClass SkeletonGeneratedClass指向一个框架类,变量或者函数添加的时候都会重新生成一次。 重点在于GeneratedClass,他指向编译完成的可以完整描述一个蓝图的class。

GeneratedClass,可以理解为一段完整的数据,他描述了某个actor或者其他实例的一切具体数据。 他在文件中通过一个uasset文件序列化出来,不过uasset也序列化了蓝图本身的信息。 pA02RMj.png

经常会在一些项目内看见一些带“C”的路径内容,比如图中的VideoView也会有一个VideoView_C的版本,这个版本就是这个蓝图的GeneratedClass,而不带C的就是蓝图本身+GeneratedClass,所以一个uasset文件其实是两个文件,编译时通过路径名进行区分。

再注意到蓝图界面的一些内容 pA02bz4.png 这个Parent的意思是这个蓝图代表的GeneratedClass的父类,而不是这个蓝图本身的父类,也许在实际开发过程中经常直接称呼XX蓝图,但是如果要理解蓝图的底层结构,那么先理清“蓝图”和“蓝图代表的类”这个概念很重要。 那么蓝图是什么东西呢?蓝图就是用来修改他所代表的generatedClass的“编辑器”,当虚幻游戏启动的时候,GeneratedClass中的信息被读取之后本身其实就不起作用了,当然可以在运行时使用蓝图对对象进行编辑,但是退出游戏之后GeneratedClass仍然没有变化。 GeneratedClass在UE中描述为_C后缀,这个类在实际游戏中往往会被实例化成多个对象,比如玩家一玩家二,怪物一怪物二,这些实例的命名加数字后缀,比如Class_C_2。

蓝图的编译:指把UBlueprint描述的信息转换为BlueprintGeneratedClass的过程

蓝图虚拟机

蓝图节点 虚幻引擎中的蓝图分为很多种,比如关卡蓝图,角色蓝图,控件蓝图等。 但是不外乎都有一个图表,表内有一些起始节点,也可以放其他事件节点进去。从节点里面拉出来事件线,构成其他条件节点或其他事件节点,这些节点最终组成一个有相无环图,可以说是C++代码中函数、对象的具象化了。 蓝图使用起来十分简单,这里不做概述,只要有一定C++基础,那么蓝图一定能够看懂。 这里要说的是每一个蓝图节点是由多个statement组成的,statement在蓝图节点中由一个array引用,他们能够顺序执行,每一条statement代表了一个操作,比如跳转、比如条件判断、比如函数调用。 statement在ue源码中,由FBlueprintCompiledStatement类代表,类中具有语句的操作枚举值、操作的函数实例等。 statement还属于可视化编程的概念,完全可以说,蓝图连连看其实就是在编写一句句的“statement”逻辑,之后如果点击了“编译蓝图”,那么这些statement就会一股脑编译成一个个字节码。

字节码 蓝图在编译后会生成对应字节码,引擎运行时会读取字节码,并交由蓝图虚拟机动态解释执行。

enum EExprToken
{
    // Variable references.
    EX_LocalVariable		= 0x00,	// A local variable.
    EX_InstanceVariable		= 0x01,	// An object variable.
    EX_DefaultVariable		= 0x02, // Default variable for a class context.
    //						= 0x03,
    EX_Return				= 0x04,	// Return from function.
    //						= 0x05,
    EX_Jump					= 0x06,	// Goto a local address in code.
    EX_JumpIfNot			= 0x07,	// Goto if not expression.
    ...
}

这些字节码都对应了一个个C++函数,可以在ScriptCore.cpp中找到,可以自行加入新字节码并实现对应指令函数。 虚拟机的本质就是FFrame类,蓝图虚拟机的唯一作用就是解释运行字节码,因为反射GC控制内存等功能都已经被UObject完成了,所以蓝图虚拟机其实十分轻量。

FFrame

  • UFunction* Node:当前执行的函数对应蓝图节点
  • UObject* Object:执行蓝图的对象
  • Step():取一个字节码运行
void FFrame::Step(UObject* Context, RESULT_DECL)
{
    int32 B = *Code++;
    (GNatives[B])(Context,*this,RESULT_PARAM);
}

GNatives是蓝图字节码和函数的映射表,函数由宏IMPLEMENT_VM_FUNCTION进行注册。 一次蓝图调用可能会出现多个frame,所以需要循环进行Step直到读取到EX_return退出。

从C++到蓝图的流程 起点为ReceiveBeginPlay蓝图事件。 1.如果C++函数被标记为BlueprintImplementableEvent,那么UHT在编译阶段就会在这个函数内生成ProcessEvent代码。 2.函数被调用时,ProcessEvent函数会创建一个FFrame,对应的Node就是ReceiveBeginPlay。 3.进行函数环境判断,如果是RPC函数,则会进入RPC的网络层调用,如果是本地函数责会调用ProcessLocalScriptFunction来分析字节码。

从蓝图到C++ 1.如果C++函数被标记成BlueprintCallable,则说明这个函数能被蓝图调用,在UHT阶段会生成这个函数的exec函数。 2.exec函数有Z_Param_Result参数(用于返回值)、Code字节码(存有函数本身的参数)、P_THIS->指针(指向函数本身并调用)。 3.蓝图调用C++,先创建一个FFrame,解析对应的字节码,如果是本地函数,则直接传入参数进行invoke。

实质上常说的蓝图调用C++函数,而C++函数调用蓝图函数其实很少,因为本质上C++是不能直接调用蓝图函数的。首先蓝图生成类不是C++NativeClass,是属于一种动态生成代码,C++没办法拿到这个函数的信息与实现,所以肯定没办法直接调用。广义上说的C++调用蓝图,其实是在特定条件下依靠一些特定方式完成的,比如例子中的BlueprintImplementableEvent,他其实也不是蓝图函数,而是声明在C++中,实现(override)在蓝图中的一种函数。也许可以通过这个函数去调用其他蓝图函数,然后实现蓝图的间接调用,不过准确讲,这些都不算C++调用蓝图。

游戏编程与引擎编程

使用虚幻引擎制作游戏的时候,绝大部分功能需求无论使用C++还是使用蓝图都能够完成。他们的侧重点略有不同。 比如使用C++编写逻辑,更需要考虑“需要为对象分配多少内容”;使用蓝图进行逻辑编写,更侧重于考虑“gameplay层面上的不同表现”。 前者更像“底层”,着重于细节上的填充,例如性能优化。 后者更像“脚本”,着重于游戏框架的搭建。

一般来说蓝图需要被翻译成机器码,然后再通过C++去调用解析,而C++本身甚至可以通过编译器进行优化,所以C++性能上要优于蓝图。 之前说过蓝图可以直接被UE转换为C++,这个UE的一个可选功能,叫Blueprint Nativization,其生成的C++代码可读性差,代码性能差,充斥switchcase,而且不能编辑。所以可以考虑在蓝图搭建好游戏框架之后,对需要运行大量tick、开销较大的蓝图进行C++重构。

蓝图与C++设计模式最佳实践

C++是面向对象的语言,一般将类定义在.h文件中,类的函数实现在.cpp文件中。 对于蓝图来说,蓝图的父类、组件、属性、函数列表就是他的类定义,而事件图就是函数实现。 面向对象有一个很重要的软件工程原则就是“脱耦合”,比如人手拿武器,人就有了武器的引用,但是武器就不需要拥有人的引用了,需要避免工程上武器对人的方法的直接调用。 一些小的工程当然很好去构建这种关系,比如武器实在想对人传输消息,那么通过提前规定好的事件就行了,在蓝图里面,这样类似的事件系统叫做“事件调度器”。

module 虚幻引擎中有模块分层的概念,逻辑上模块之间完全分离,需要模块之间显式引用,显式引用的模块,自身所引用的类和方法就暴露给了别的模块了。 例如虚幻引擎中固有的“Pawn”类和“Weapon”类。这两个类存在两个不同的模块当中,完全分离,但是weapon被核心模块引用,所以pawn可以单方面调用到weapon。

那么再来回顾一下模块的作用吧

  • 控制构建时间
  • 代码所有权确定
  • 责任界定

缺点: 自定义模块比较麻烦,而且自定义模块也会被纳入虚幻模块体系当中,需要按照默认的设计规矩走

蓝图依赖 蓝图没有模块这个概念,蓝图依赖于所有的C++模块。只要C++代码中有类型被标记为“BlueprintType”,那么蓝图就可以引用他。 蓝图是资产,所以蓝图可以利用项目中资产的原理来管理蓝图。比如说资产可以查找他们的引用关系,这一招在蓝图上同样奏效。如果是CPP文件,我想大家可以试试使用vs的虚幻C++查找一下一个类的依赖或者一个函数的引用关系,保证有够受的。 蓝图一个最大的优势就是可以直接调整参数,比如要引用某个特效,恰巧这个特效频繁在改变,那么使用蓝图就是最佳的选择,毕竟使用C++需要频繁更改源码的引用路径,还要重新编译。 如果要使用事件,那么使用蓝图也是一个很好的选择,因为蓝图的事件系统非常直观(如果有过使用Unity引擎写事件系统的经历应该会有更大的理解)

当然蓝图最大的优势仍然是:别人看得懂,因为不是所有人都懂C++ C++性能上的具体优势:可以针对目标平台进行优化、源代码直接编译为机器码,没有多余开销 C++的其他优势:绑定保存加载期间发生的底层事件、添加编辑器模块、添加C++链接库、创建自定义布局、绑定引擎编辑器事件等 C++工作流上的优势:蓝图是二进制文件,查看蓝图必须通过项目编辑器对蓝图进行解释,而C++代码是纯文本的,本身是可以通过svn、git等工具做到一个分支合并的功能

举个例子 一个Pawn,一把武器,在蓝图中可以分别继承defaultpawn类和weapon类,绑定完后为weapon加一个fire事件,fire之后会启用开火特效,这个事件由pawn来触发。 这一个需求使用蓝图实现起来非常简单,因为所有事件系统在蓝图里面非常直白。 实际生产过程中需要将pawn使用C++进行重写,因为可能这个pawn需要面临比如ai逻辑,或者物理模拟之类的情况,那么使用C++保证性能是一个更好的选择。现在将pawn进行重写,但是发现pawn用不了weapon了,因为这个weapon是在蓝图中定义的,是动态生成的,C++只知道这个weapon是AActor的子类。 所以这个pawn哪怕真的通过路径获取到了weapon的实例,那他也调用不了fire函数,因为pawn压根不知道这个actor有fire行为,这是个未定义的。(除非使用反射强行查找,但是这样子更加扯淡了) 如果这个weapon本来就是C++就好了,那么pawn就能直接引用他了。 说干就干,那么使用C++也重写一次weapon。 特效、fire的实现、所有资源的引用都在C++中硬性编写了出来。。。 这下又违背了资源引用的灵活性了,每一次修改开火的特效,都要重写一次C++然后重新编译! 而且资产被C++强行引用了,发现每次启动游戏角色的武器都会卡一段时间再加载出来,因为文件加载到内存也是一件麻烦事。那就写成static好了!让游戏启动的时候加载内存。这下这把武器全程都要在内存里躺着了,那到底啥时候该加载他呢,这下谁都不知道了。 UE自然能帮我们处理所有的麻烦事,只要善用蓝图和C++之间的联系。 比如BlueprintImplementableEvent标记,他表示这个事件在蓝图中被实现。那么我们可以将fire函数用BlueprintImplementableEvent标记一下,然后再蓝图中实现这个fire函数,这样C++也可以调用到这个fire了。 其他的需要蓝图“实现”的事情同样也可以这样实现,比如资产网格的载入,蓝图本身就有网格组件,我们将他绑定在weapon蓝图上,这样就不需要C++来管理他了。然后weapon蓝图呢,他的父类直接换成C++父类,这样C++也可以调用他在C++.h中声明的函数了,就比如说fire函数。pawn本身也需要有一个蓝图,因为weapon要被使用,而C++本身无法直接调用蓝图。所以用一个继承C++的pawn类的蓝图类,用这个蓝图绑定weapon蓝图,这样一个层次结构清晰的简单的蓝图——C++模式就诞生了。