Tencent / puerts

PUER(普洱) Typescript. Let's write your game in UE or Unity with TypeScript.
Other
5.08k stars 707 forks source link

【UE5】【讨论】继承引擎类功能实际使用体验以及和Mixin功能的对比 #1791

Open llint opened 4 months ago

llint commented 4 months ago

这两天深度试验了一下UE5下Mixin功能以及继承引擎类功能,分享一下我的体验

继承引擎类功能

继承引擎类功能的本质是Puerts自动 基于继承引擎类的ts脚本 生成对应的蓝图资源(代码:PEBlueprintAsset.hpp/CodeAnalyze.tjs),这样在UE编辑器中就可以对这个蓝图资源进行正常引用。比如一个TS_Player.ts内定义一个继承于UE.CharacterTS_Player类,并将其默认导出,则Puerts会自动识别这个类,然后自动生成对应的TS_Player.uasset蓝图资源,该资源的父类和Native类均为引擎Character类。

生成对应蓝图资源的路径根目录由JsEnv.Build.csTS_BLUEPRINT_PATH定义,默认为[/Game]/Blueprints/TypeScript目录。生成的蓝图资源内的方法(或事件)与对应的ts脚本之间应该是做了类似蓝图Mixin功能的重定向(Mixin的实现逻辑我反而还没来得及看),这样蓝图内的方法或者事件被调用或者触发的时候,实际逻辑会被导向到对应脚本函数的逻辑。生成的蓝图资源本身就是普通的蓝图资源,并且生成的蓝图资源可以像普通蓝图一样进行修改(*)。

继承引擎类的成员变量,除了ActorComponent类型(或子类),默认在生成的蓝图里是不可见的 - 比如文档中的FpsCamera成员因为是一个ActorComponent子类,因此在蓝图中的Variables.Components下默认可见(但不可删除),而文档中的tickCount成员在生成蓝图中默认是不可见的。要让非ActorComponent类型的成员可见,需要对这些成员进行uproperty标注@uproperty.uproperty(uproperty.BlueprintReadOnly)

在生成对应蓝图资源的时候,系统会尝试加载对应路径下同名资源。如果资源不存在,那么就基于ts脚本生成一个新的蓝图资源;如果蓝图资源存在,目前Puerts的逻辑是以ts脚本为 原型,对存在的蓝图资源内同名的成员和方法做类型及参数调整(如有变化),最后删除蓝图中在原型ts脚本中不存在的成员和方法。

上述行为导致的结果是:虽然生成的蓝图是可以编辑的,可以在蓝图中增加新的成员变量及方法,但是一旦对应的原型ts脚本触发重新编译,那么上述行为会导致蓝图中新添加的成员和方法自动被删除。因此从使用流程的角度来讲,其实生成的蓝图是不应该被修改的,否则任何对于蓝图的修改都将在下一次原型ts触发重新编译的时候丢失!而且因为蓝图本身的不方便diff的特性(当然可以通过编辑器和P4/Git的集成进行diff),在团队合作过程中可能会导致比较大的问题。我不知道蓝图是否有一个标志可以将蓝图置为不可修改的状态来避免这个问题。或者另外两个思路:

  1. 不删除新添加的成员和方法;但是对于 合并 修改过的成员(类型改变)和方法(参数和返回改变)就比较麻烦 - or
  2. 直接在蓝图里面标明该蓝图由对应的ts脚本生成,不可更改;然后Puerts的蓝图生成逻辑就可以更加简化:如果对应蓝图存在则直接删除这个蓝图,从零开始基于ts脚本重新生成该蓝图,这样原来的追踪和删除新添加和改变的成员和方法的逻辑就可以简化

所以实际体验下来,继承引擎类功能最大的优点可能只是可以通过只写一个ts脚本,实现自动生成对应蓝图的效果,而该蓝图其实并不包含任何实际的逻辑(逻辑都在原型ts脚本内),还不能修改,所以这个生成蓝图的功效并不大。

Unreal Engine Angelscript实现了蓝图虚拟机的自动注入(需要引擎改动),不需要生成中间蓝图的方式,我觉得可能是最佳案例,但是可惜的是这个方案需要引擎的改动。

我甚至还考虑过通过原型ts脚本生成对应C++代码的方案,优势是代码diff更加直观,生成的C++类在蓝图虚拟机中不可直接修改,和其他C++引擎类一样可以在蓝图中继承并修改属性值。但是因为生成的C++代码需要编译,这又失去了脚本的动态优势 - 想想其实可能还好:因为无论是生成的蓝图还是C++代码,其实目的就是生成一个架子(skeleton) - 这个架子主要是成员函数的申明,而实际逻辑还是存在于ts脚本中 - 只要脚本控制对架子(成员,成员函数及参数或返回)的修改频率,仅仅是修改逻辑本身,那么生成的C++架子(skeleton)是相对稳固的 - 比如只修改了逻辑,但是没有修改成员变量和方法,那么生成的C++代码应该是完全一样的,不会触发CI/CD二进制构建的更新,也就不会影响相应的热更流程(热更只需要更新ts/js的逻辑,而没有二进制代码的改动)。

蓝图Mixin功能

对比于继承引擎类功能,蓝图Mixin功能的流程是:手工创建蓝图,并且可以自由的修改,然后使用蓝图Mixin的功能在对应的脚本中对蓝图方法进行覆盖的流程(类似UnLua的标准流程),相对而言可能是Puerts在UE5引擎下更好的一个方案(本文不讨论其他交互方式,比如静态模版注册和delegate的方式)。

为了更加方便的使用蓝图Mixin功能,在 https://github.com/Tencent/puerts/issues/1786 中,我给出了一个建议性的对蓝图Mixin进行自动注册的方案,我个人认为UnLua的蓝图脚本逻辑覆盖的方式是最符合UE5原生建议的方式 - UE中的脚本就是蓝图,而ts/lua作为蓝图脚本逻辑的扩展,这个思路我个人认为是比较合乎逻辑的。

其他的脚本使用方式

之前的讨论提到脚本的入口目前是通过JsEnv.Start函数可以加载一个入口脚本一次的方式,这个没有问题,UnLua的标准模式甚至用户都不需要显式调用任何虚拟机start的方法。这个Start入口脚本正好被 https://github.com/Tencent/puerts/issues/1786 提到的方案作为脚本注册和脚本系统初始化的理想之地。

是否还有其他的一些交互方式,我基于我的想法(没有实际经验)做了一些猜测(不一定适用虚幻引擎):

  1. 通过JsEnv.Start启动类似一个Main.ts的脚本,整个游戏的核心逻辑由ts脚本驱动,那么在这个Main.ts脚本里面需要做的事情可能会包括各个脚本框架系统的初始化,注册脚本系统的顶层tick方法到虚幻的tick系统,以及核心引擎事件处理机制通过delegate的方式被注册到相应的脚本入口,etc. etc.,这样在Main.ts返回之后(注意main.ts不得不返回控制权给引擎),脚本系统的“引擎”层开始被tick所驱动,同时引擎的核心事件触发的时候也会触发被注册的脚本系统逻辑。
  2. 该系统基本上就跳过了上面说的蓝图逻辑,直接将引擎逻辑和ts脚本逻辑绑定起来,蓝图在这个方案下可能只是作为类似于Unity的Prefab资源的方式进行使用,可以在需要的时候在脚本里面显示的加载使用。
  3. 这样看起来整个游戏可以完全以一个比较自包含的ts脚本框架来写 - 这就比较符合传统脚本框架的使用方式 - 所有的逻辑我要么去看引擎C++代码(可能还是会根据需要写一些C++代码,然后使用静态模版的方式调用C++代码或者是Puerts原生方式调用C++代码),要么所有的逻辑和流程都集中在脚本框架中,虚幻这一套gameplay的框架都可以完全跳过。

希望能够抛砖引玉,不正之处请直接指出,一起提升。

QirangMilco commented 2 months ago

非常感谢楼主的分享!个人感觉c++到蓝图到ts的方式可能是比较适合虚幻引擎的。至于提到的另一种方法,直接跳过蓝图,把引擎和ts联系起来,我自己尝试的时候感觉可能还是要做一些脏活累活,反而浪费了蓝图这样一个工具。(我猜测PuerTS建议的方式也是蓝图Mixin)