找回密码
 立即注册
查看: 229|回复: 6

[笔记] 【引擎研发】使用py3.11作为引擎脚本

[复制链接]
发表于 2023-2-1 17:55 | 显示全部楼层 |阅读模式
前言

首先赶在假期末尾祝大家新年快乐~
前言就是本人的一点碎碎念(会有点长QaQ),大家可以直接跳到正文哟~
上一次发文已经是七个月之前了,目前已经加入网易引擎组半年了,这半年很愉快,导师不仅是校友,更是大牛且非常nice(总之就是非常好!)。前段时间想到了之前的毕设,想继续完善下去,因此在周末补番之余会抽点时间接着完善。
不过因为想完善好再整体放出来,之后再开个专栏或者做做教程,因此把之前的项目设为private了(第一次那么多的star就没了哭),现在公开的地址在:
https://github.com/hebohang/HEngine
本人上传了第一份release版本的,位于:
https://github.com/hebohang/HEngine/releases/tag/v0.0.1
当然release版本所用的代码并不是公开地址的代码,而是我本地在写的代码。也欢迎大家多多关注公开的地址哟,未来会更新上去。
技术演示



https://www.zhihu.com/video/1602352390186967040
要素察觉

视频主要演示的几点:游戏引擎调用py代码、写代码时的自动补全、编辑器热更代码。当然由于我们使用的是py,代码的热更会变得很简单,不过也有一些细节,我会在正文处讨论。
未来工作

首先要感谢学长 @Mike Smith ,未来有使用Luisa的打算,毕竟我的图形功底和几位大神之间差的太多了:
但是发现以后可能会变成图程的形状,所以打算先自己补其他图形后端当作锻炼,目前正在写dx12的后端以及整顿渲染层架构,由于本人水平不足可能会写很久。
另一个主要工作是做自定义的资源格式。目前引擎(下面用HE替代)全面使用 json 格式,所用的是nlohmann_json这个库(最快的应该是rapid json,字节的json库似乎要用到go当后端解析,但是这个json库是真的很好用,且性能不会差多少)。我也基于这个库做好了C++的代码反射与序列化反序列化,因此一个Entity的序列化过程只需要几行代码便可以搞定:


后面的脚本层的代码自动绑定也会用类似的手法,这将会在下面正文部分讲解。
上面放出的release版本有了一点点雏形,在 Package\Assets\Test 里面会有目前定义的资源格式的形状,但是还没有完善也没有测试呐。导入资源做的是插件的方式导入,不过也只写了个结构,离真正可用还差很远。
引擎架构实践

其实关于引擎结构的划分就能单开一个文章讲了,引擎一般会把资产包在一个文件夹,这样可以做到支持多个项目、区分引擎内置模块与项目特定资源以及最终打包的一些便利。下载前言的HE放出的release版本会看到项目是用package文件夹的形式放进去的,而Engine\Binaries\Win64中的 HEngineEditor 则是调用 package 中的资源,在引擎编辑器中呈现。
这其实是一种工程实践了,在 UE 中会把资源和代码区分开(前者包在Content中,后者则包在Source中),在Unity中则是全部放入Assets中,而HE则是全部放入 Package 中(代码需要固定放在 Package\Scripts 里面)。
可以看到 两 U 引擎 其实蕴含了无穷的智慧,且他们的脚本层设计也截然不同,本人的设计主要是观摩了 Unity 的设计方式,再予以实现。
本人是游戏引擎的初学者,在知乎上看到的最强的专栏当属文礼老师 @文礼 的:
阅读这个专栏让我受益匪浅。但是一个引擎实在是过于庞大,即使是老师上百篇的文章也有很多细节难以涉及。本文也是网上较少涉及的话题(包括前文的OBB生成与引擎矫正)。下面将具体介绍本人的脚本层设计与实现。
胶水层——连接py与cpp

可以参考:
用 C/C++ 扩展 Python,pybind11, ctypes, Python C API 如何取舍?_哔哩哔哩_bilibili
选择

Python C API:麻烦,不用。
ctypes / swig:cpp调用python做不到?不太了解。
因此最后仍然是传统的两个方法中选择:boost.python vs pybind11
boost.python:boost 太庞大了,森罗万象,经常牵一发而动全身(一下子就得全包进来)。好用是真的好用,但是个人感觉现在是每一个子模块都会有更好的实践出现,就例如其子模块 boost.python,相对应的出现了专注于服务 cpp 和 py 连接的 pybind11(本人可能认知不准确,望指正)
pybind11

关于pybind11的具体细节可能也需要单独开一篇文章讲解,而网上大多最佳实践、工程实践等文章其实介绍是很浅的,入门可能够用,但是对我有帮助的最后只有官方的文档。本文只讲大概的几点。
首先是如何不依赖本地的py解释器。所有的文章以及官方文档都是会在构建时找好本地的py解释器,然后获取到路径就能用了。但是对于引擎调用而言,这样做根本不能打包!真正的解释执行应该让引擎去执行。
本人使用的是最新版的pybind11-2.10.3。并且发现在解释器初始化的时候,py版本高于3.8的,和其他的是两种初始化方式。我们知道py的初始化是需要一个 PyConfig,里面会包含着各类信息,详细参考官方文档:
Python Initialization Configuration
The initialization of the sys.path module search path
我最后是阅读pybind相应代码,在cmake中通过指定:
set(PYBIND11_NOPYTHON  TRUE)来让其不要寻找本地解释器,再自己更改一些初始化方法,使其可以正确找到 Package\Scripts 路径(也可以简单用 sys.path 解决,但是本人通过修改 PyConfig 中的信息,并且可以在解释器初始化的时候就获取到可以import的lib路径,个人感觉更为优雅)
py脚本设计

问题一:正确执行对应代码

HE 是纯 ECS 架构,脚本的设计也非常的unity,和UE是大相径庭:
我们需要在一个entity中添加一个PythonScriptComponent,然后把一个py脚本赋给这个entity,component记录的则是这个脚本的路径。这样做是为了引擎能够正确执行py脚本。
但是!游戏逻辑开发的主流旋律还是要面向对象,比如两个怪物,如果他们仅有一点功能不一样,那么可以用py脚本写对应的类,通过继承的方式则更加优雅。
如果简单在py中定义一个类,我们还需要在上层引擎层面能够获知这个类名信息,否则无法在cpp中执行正确的方法。Unity使用的是monobehaviour,通过固定类名和脚本名一样,从而可以获知这一信息。于是HE也如法炮制,简单示例:
# MoveRight.py
import HEngine

class MoveRight:
    ...这样做,我们在把 MoveRight.py 赋给一个 entity 的时候,就可以知道路径是 Scripts/MoveRight.py,同时简单解析一下这个字符串就可以知道类名是 MoveRight,于是就可以正确在 cpp 中调用了。
问题二:脚本中获取entity

这无疑是很重要的。在Unity中,由于每个类都继承自 MonoBehaviour,我们自然可以在上层做很多调整,同时也可以直接用 gameObject ,更自然的,我们可以直接用 gameObject.GetComponent 就可以获取到同一 entity 的其他组件,且 C# 天生就有泛型,而 py 则是一个动态类型系统(所有的类型在runtime下指定,在pyobject结构体中会保管此时的类型是什么)。
参考:GameObject.GetComponent
这一步也让我想了很久,最后的设计是:
# in MoveRight.py
import numpy as np
import HEngine

class Props:
    step = 5.0
    ignore__ = 111
    handsome = "hbh"

class MoveRight:
    def __init__(self) -> None:
        pass

    def Start(self, gameObject:HEngine.Entity):
        return

    def Update(self, ts:HEngine.Timestep):
        return

    def Stop(self):
        returnStart 函数和 Stop 函数会分别在游戏运行开始的时候和结束的时候调用,Update则是每帧调用,ts 传入的是上一帧所用时间。
而在 Start 中,我们会传入 gameObject,此即引擎层面的entity。我们若想保存entity的一些属性,则可以在Start函数中保存,再在Update函数中使用即可。
问题三:GetComponent 方法

非常自然的,我们想这样写:
class MoveRight:
    def __init__(self) -> None:
        pass

    def Start(self, gameObject:HEngine.Entity):
        self.gameObject = gameObject

    def Update(self, ts:HEngine.Timestep):
        trans = self.gameObject.GetComponent("Transform")Start函数中保存了当前的entity,Update函数中获取这个entity的TransformComponent,进而完成调用。
这是我最直接的设计方法,但是要怎么在cpp中完成实现呢?
这个问题苦恼了我很久,我们知道函数重载其实就只是mangling之后不同罢了,但是这是不能用返回值的type来重载的,否则我们无法获知调用哪个函数。因而也就不能简单地指定不同的返回值类型了。
并且我们不想在脚本层再去指定他的type,试想,这样的代码有多麻烦:
trans = self.gameObject.GetComponent("Transform")
trans.type = HEngine.TransformComponent因此我们需要把所有的type信息都包在返回值里,自然想到cpp17的 std::variant
本人是一个现代cpp拥趸者,只要LLVM和MSVC可以支持新版本的cpp我就会打算升上去(目前HE仅支持Windows,直接使用Clang-cl和msvc测试,并且Clang比GCC更年轻、优化更激进)
但是这里有个小细节,因为我们想要在脚本层中直接修改entity的属性,这就必然要传引用或指针,而引用必须要有初始化,因此不能粗暴的在variant中使用引用类型(optional是可以的),所以我的方法是指定返回值为:
std::variant<std::reference_wrapper<TransformComponent>, std::reference_wrapper<TagComponent>, ...>
这样,脚本层的问题就解决了。可以在技术演示中的视频看到我们在py中获取到entity的transform component,并且编写代码能够用 wsadqe 控制小方块的transform属性。且全程有很不错的代码补全哟。
后续工作

目前还有一个大块没有完成,就是脚本属性的序列化反序列化以及在编辑器下的属性暴露。
在前面“问题二”的示例代码中可以看到有一个 Props 类,这是本人最初的设计:所有属性在一个全局类Props中暴露,后面加下划线 __ 的表示不暴露,也不会被序列化保存。
但是后来想是不是直接在一个类的 init 函数中写就可以呢?不需要保存在 Props 中?
不知道大家怎么看,脚本层还有很多要完善的工作,之后可能还会改设计。
多线程

我们知道py是有GIL这样一个全局锁存在的,并且一些模块是不能导入两次的(好像numpy就是,忘了),所以在每个脚本执行的时候去初始化一个解释器再执行脚本、导入模块是不行且效率低的。
依据官方的说法,我们似乎应该在全局定义一个解释器,这是目前HE的main函数的全部代码:
int main(int argc, char** argv)
{
        // init path first
        HEngine::ConfigManager::GetInstance().Init(HE_XSTRINGIFY_MACRO(ENGINE_ROOT_DIR));

        HEngine::ScriptEngine::GetInstance().Init();

        HEngine::MyAppInitialize(HEngine::Application::GetInstance());

        {
                HEngine::Application::GetInstance().Run();
                HEngine::Application::GetInstance().Clean();
        }

        HEngine::ScriptEngine::GetInstance().Shutdown();
}
但是要是在多线程环境,每个脚本执行的环境并非是主线程,就会和GIL矛盾从而出错(真是头疼。。)
我的解决方法是在每次脚本执行前加上两句:
py::gil_scoped_release release; // to release the GIL
py::gil_scoped_acquire acquire;不过本人对这一块还并不深入,尚未明白这样做有什么后果,不过目前是可行的。
Component自动绑定

在前言中曾简单介绍了 HE 目前的序列化方案。大前提是做好了反射(其实主要是做好了codegen啦,个人认为使用Clang进行codegen仍然是目前游戏引擎领域的最优解之一)。我最后是在codegen中生成了可用的绑定函数(就是把每个component类给注册进脚本层),而每个component的反射则需要使用特定的宏REFLECTION_COMPONENT_BODY,这个宏的作用就是普通的反射 REFLECTION_BODY 加上一个绑定脚本层的函数声明:
#define REFLECTION_COMPONENT_BODY(Type)         \
    REFLECTION_BODY(Type)                       \
public:                                         \
    static void PyBinding(pybind11::module_& HEngineModule);
真正执行绑定的代码也就变成了几行就完事了(注:这里的模板类别 Component 是定义的一个 Concept):


编辑器热更新

这部分不难,因为py本来就是解释执行的,热更在pybind里面就是一个 reload 方法调用罢了。不过仍然有一些细节:
由于我们的设计是类的设计,因此不能每次执行才创建这个类,否则两帧执行的类都不是一个类了;
因此我们需要在之前就把模块以及类信息给注册进引擎runtime。
我的做法是,在场景反序列化的时候,就会把对应的 module 以及对应的 class 给注册进引擎,做了三个全局的哈希表:
static std::unordered_map<UUID, py::module_> ModuleMap;
static std::unordered_map<UUID, std::string> ClassNameMap;
static std::unordered_map<UUID, py::object>  PyClassMap;
编辑器下热更即在每次 Start 函数调用前调用一次 reload 方法(因此,不仅仅是 module 要 reload,相应的 class 也必须要重新创建出来)。而在每帧的 update 里只需要通过entity的uuid,去拿到对应的 class 执行方法即可。
结语

历尽千辛万苦,我们终于设计出了一套使用 py3.11 作为脚本的 api 大体设计,并且可以在多线程环境下使用。
py3 当脚本是有历史原因的。害,其实就是我当时做毕设要对学长的项目进行仿真,学长是一个py3的项目,当时用了pybind,指定的本地的anaconda的py3环境,且做了不少hard code才搞定的。
最后,欢迎大家多多star新地址:https://github.com/hebohang/HEngine
红豆泥阿里嘎多米娜桑!

本帖子中包含更多资源

您需要 登录 才可以下载或查看,没有账号?立即注册

×
发表于 2023-2-1 18:03 | 显示全部楼层
[调皮]
发表于 2023-2-1 18:05 | 显示全部楼层
[爱][红心][害羞][爱心]
发表于 2023-2-1 18:13 | 显示全部楼层
其实现在的LC已经是用py作为主要语言了
发表于 2023-2-1 18:15 | 显示全部楼层
看到了,刚刚发现居然也是用的nlohmann的json库和pybind哈哈,super luisa
发表于 2023-2-1 18:22 | 显示全部楼层
nlohmann json马上就从LC里拿走了,但在应用里依旧会用,pybind一时半会拿不走。。
发表于 2023-2-1 18:31 | 显示全部楼层
原来如此
懒得打字嘛,点击右侧快捷回复 【右侧内容,后台自定义】
您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

小黑屋|手机版|Unity开发者联盟 ( 粤ICP备20003399号 )

GMT+8, 2024-5-2 01:18 , Processed in 0.134654 second(s), 27 queries .

Powered by Discuz! X3.5 Licensed

© 2001-2024 Discuz! Team.

快速回复 返回顶部 返回列表