UBlueprint
https://zhuanlan.zhihu.com/p/450520990
UBlueprintCore中的核心内容
- TSubclassOf< class UObject> SkeletonGeneratedClass;
- TSubclassOf< class UObject> GeneratedClass;
TSubclassOf意思是模板的UClass SkeletonGeneratedClass指向一个框架类,变量或者函数添加的时候都会重新生成一次。 重点在于GeneratedClass,他指向编译完成的可以完整描述一个蓝图的class。
GeneratedClass
,可以理解为一段完整的数据,他描述了某个actor或者其他实例的一切具体数据。
他在文件中通过一个uasset文件序列化出来,不过uasset也序列化了蓝图本身的信息。
经常会在一些项目内看见一些带“C”的路径内容,比如图中的VideoView也会有一个VideoView_C的版本,这个版本就是这个蓝图的GeneratedClass,而不带C的就是蓝图本身+GeneratedClass,所以一个uasset文件其实是两个文件,编译时通过路径名进行区分。
再注意到蓝图界面的一些内容 这个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++模式就诞生了。