游戏引擎的编辑器是一个比较尴尬的模块,一来是很多程序员觉得它技术深度很低,而且涉及很多UI,用户交互的功能;二来是它对于引擎的重要性又非常高,引擎香也怕编辑器不好用;三来是因为编辑器还是有一定复杂度的,类似Maya之类的工业软件开发,游戏程序员并不擅长理解大规模软件工程,架构下有游戏引擎,游戏专业性中间件等的依赖,上有非常多零碎的小功能需要管理,比如插件,资源商店,与其他DCC软件的互通工作流等。

技术选型

路线上,第一种是引擎先自举UI模块,编辑器基于引擎自绘UI框架来制作;第二种是使用操作系统或者语言等提供的外部UI框架来制作。

其实从研发角度来说,第一种肯定是最好的,做编辑器的同时可以把自己的UI模块开发,测试一遍,因为是自己渲染的,所以很容易跨平台,UI也能定制得比较好。例如Unreal Engine是引擎自己开发的Slate UI来做编辑器。

但是从实际上来说,第二种方案程序员参与开发的门槛更低,比如使用C# Winforms/Wpf这些,微软有详细的文档,StackOverflow有各种问题的解决方法,基本上面向google编程就可以了。而第一种方法遇到问题要么自己跟UI模块源码,要么联系这个模块的程序员进行沟通。

所以,为了低成本地快速得到一个能用的编辑器,我会选择第二种做法,Sony在GDC 2014开源的Sony ATF就是一套C# Winforms/Wpf的编辑器框架,同时也有LevelEditor的例子可以参考怎么利用框架开发。缺点是:编辑器只能在Windows下跑,UI风格是符合系统UI的,不能进行很多的定制化的UI设计。

LevelEditor工程分析

这个工程分三块:

  • ATF框架代码,也就是”Atf.***”命名的项目,通常是不用上层进行修改,作为编辑器项目外部依赖组件的,但实际上因为Sony的这个工程主要面向inhouse的内部项目,能用就行,所以代码质量比较一般,比如当你想修改编辑器界面的某些图标时,你会发现某些图标的ico,png资源是嵌入在ATF的dll里的,需要重新编译ATF的dll

  • LevelEditor逻辑代码,简单分了一层Core和非Core的,Core里主要是抽象一些接口类,引擎代理类,编辑器命令类,工具类等;非Core是具体业务,也是启动项目,比如动画编辑器,地形编辑器等。但实际上也会有一些不合理的发现,比如序列化/反序列化的模块我觉得应该是Core的内容

  • DirectX11的例子渲染器,C#负责引擎API绑定的模块。这部分主要是渲染相关API设计,C#这里的代码主要是关于编辑器DesignView,GameView等渲染对接的功能,然后通过dll的方式调用引擎C API

总体来说小问题还是有一些的,比如winforms有些窗口的设计视图无法打开,项目分层了但没分干净等。不过还能接受,毕竟能免费得到一个开源协议非常宽松,现成能跑的游戏编辑器,同时它的大部分代码编写还是比较干净的,有合理的命名规范,详细的代码文档,渲染器API也有很多参数,返回值注释,代码分也清楚了文件夹和工程。

与自己的引擎对接

我建议的做法是先浏览一下LvEdRenderingEngine.h里导出的所有C API,然后看懂之后,我只需要把这个文件里的API导出部分全部拷贝到自己的引擎代码里,把实现层全部改成空函数。然后把未定义的其他类型前置声明,可以通过编译就好。这样子编辑器仍然可以编译启动,只是某些跟渲染画面有关的功能被破坏了。

接下来就是按照LvEdRenderingEngine的API去还原渲染画面了。可以在LevelEditor项目属性的Debug页面Enable Native Debug,然后直接在这个C++渲染器里挂断点。

这里我们只提最少的跟画面输出到编辑器内渲染相关的API:

  • 初始化 LvEd_Initialize

    • logCallback
      • 这个是C#那边的一个包装Console.WriteLine之类的log输出函数,只要把它包装成Delegate类型,然后传到引擎这里,是可以直接当函数指针来使用的
    • invalidateCallback
      • 类似的callback,用于主动告知editor当前的view需要刷新
    • outEngineInfo
      • 输出一份关于初始化引擎信息的xml字符串给编辑器,用处比如是告诉编辑器引擎当前支持的模型,贴图格式
    • 代码逻辑
      • 按序初始化各个功能模块
        • DeviceManager : DirectX11初始化,渲染设备
        • GpuResourceFactory : 创建GPU资源的工厂类,传cpu buff创建VB,IB,Texture,Shader等
        • RSCache : 缓存渲染状态描述对象,比如RasterizeState,DepthStencilState,BlendState,SamplerState等
        • TextureLib :CPU端的Texture管理
        • ShapeLib : CPU端的一些程序化生成几何体,可能是这个编辑器还打算支持一些简单的建模
        • ShaderLib :管理加载好的不同功能Shader,Billboard,Skybox
        • FontRenderer :字体自绘,按字体生成quad和贴图
        • ResourceManager :加载磁盘资源到CPU内存
        • RenderContext :管理渲染的其他上下文,camera,light,fog等
      • 自己引擎要接入的时候,需要剥离开窗口设备和渲染设备到两个类里,对于Editor来说,只需要渲染设备。同时要把游戏循环拆出来,对于编辑器来说,我们只需要提供一个单帧画面驱动的API,C#端可以自己控制触发的频率,比如DesignView的帧率它可以设个30,或者只有数据更新时才刷新,或者有那种动态效果时才更新。当然,你也可以选择把渲染循环跑在一个单独的线程里之类的做法。
  • 对象管理相关的API

    • 编辑器操作引擎对象的第一种做法是,写各种各样对象具体怎么操作的API;第二种是元数据驱动,把引擎对象理解成一份xml数据,编辑器只会修改这份xml,然后引擎检测到新的xml之后可以按最新的配置重建这个对象,或者刷新。
    • LevelEditor用的类似第一种做法,但是设计了一套方法简化API编写,用少量的性能开销换取Editor开发便利:
      • u64 ObjectGUID
        • Native层对象的ID,直接取对象内存地址就好了,x64下就是u64
      • u32 ObjectTypeGUID
        • Native层每一个类名当作字符串,通过Hash函数生成TypeID,全局唯一
      • u32 ObjectPropertyUID
        • 由编辑器向Native层注册的C# Property,也是通过字符串哈希生成的唯一ID,但是全局下由于不同类可能有同名的属性,所以还要和ObjectTypeGUID两个u32拼一个u64,在运行时使用确保全局唯一,下面简称tpid
        • 编辑器注册C# Property的时候,同时会把Set,Get函数也一起注册进来,tpid存表里
      • u32 ObjectListUID
        • Native层数组类型容器(vector, list之类)的ID,也是容器起个名字进行哈希,再跟type拼u64保证全局唯一,同时C#会把容器的add,remove方法注册进来
      • 有了这些设计之后,我们只要导出关于这些ID的注册,释放,Get/Set方法就可以完成所有运行时对象管理了。举个例子,我们在编辑器初始化的时候想要为DesignView,GameView各自创建好一个交换链对象,然后把Window Handle传过去绑定好。这个过程在这套设计下,变成了:
        • C++提前注册好”SwapChain”对应class SwapChain,然后把创建该对象的函数指针也绑定过去
        • C#控制初始化的时候,调用GameEngine.GetObjectTypeId(“SwapChain”)查询C++层class SwapChain的类型ID,取名swapChainId
        • C#调用创建SwapChain对象,把swapChainId,窗口句柄,再加上指针的大小一起传过去即可,GameEngine.CreateObject(swapChainId, this.Handle, IntPtr.Size);
        • CreateObject这个API在C++层会按照ObjectTypeGUID找到class SwapChain之前绑定的创建函数指针,把Handle解释成IntPtr.Size的数值转换成参数类型传进去,调用
        • 所有C++注册过的类型,可以搜索GobBridge::RegisterObject查询,每个类型编辑器可以修改的属性,也是类似的方法GobBridge::RegisterProperty。其实也是可以更加方便的,通过在C++里读取Xml的类数据,自动注册
      • 所以LevelEditor所有运行时对象都是由这种取巧的方法驱动的,除了一些API调用可能没有抽出成对象,或者调用函数不适合这样子做,只剩下很少的一批了:
        • 字体,DrawCall,VB/IB创建,渲染帧Begin,Update,End,修改RenderState,选中,设置当前打开的关卡文件

    回归主题,我们可以借鉴API设计的框架,把运行时对象注册,管理的方法借鉴过来使用,会让我们多出一些映射u32,u64到函数指针的哈希表,但是可以少写很多API代码。然后C++里注册类型的代码,我们可以暂时先手写,然后加上用一个xml文件跟class完成互相import/export,序列化/反序列化的meta data设计,最后读取xml文件来完成注册。xml注册这一步是引擎启动期间,所以未来C++也是可以利用模板元编程优化掉的,不过需要优化的情况应该很少,需要注册来给Editor使用的类型数量是不多的,不至于卡在这一步,同时编辑器项目启动慢几秒也无所谓,主要是稳定好用。