【ZeloEngine】重新理解CMake

【ZeloEngine】重新理解CMake

不幸的是,使用C++创建小型项目的技术完全不能直接用于大型项目,这种扩展的效果不佳,缺乏经验会导致很多问题

引子

这两天重构了ZeloEngine的cmake脚本,正好重新思考一下,谈论cmake时,我们到底在解决什么问题

本文先说结论,然后讨论cmake知识,然后讨论怎么应用在这次重构中

重构结论

构建时间

41.547356s => 37.4614157s,稍微优化了一些

Visual Studio工程目录

现在:干净,少量target
在这里插入图片描述
现在:target展开目录和文件目录一致
在这里插入图片描述
原来:Core中每个子模块都是一个target;target里所有代码挤在一起;没有包含.h
在这里插入图片描述

构建依赖图

现在:相对干净,每个节点连接数合适
在这里插入图片描述
原来:混乱,像spdlog日志,glm数学,这种所有target都会用到的库,连接会很密集,这是没有意义的,干扰分析
在这里插入图片描述

cmake脚本

cloc 715 => 583

-------------------------------------------------------------------------------
Language                     files          blank        comment           code
-------------------------------------------------------------------------------
CMake(OLD)                 35            123             91            501
CMake(NEW)                37             97              74            412

怎么做到的?

  1. 构建时间,我猜测是合并了细小的target,优化了链接时间,编译时间应该不会受到影响
  2. 项目工程结构,一是合并了细小的target,二是利用cmake的source_group构造目录结构
  3. 构建依赖图,利用cmake interface library,整合构建属性,就像束线带一样把连接按模块分组,减少连接数
  4. cloc,主要靠file_glob替代手写文件列表;引入了Module和Private概念,让脚本更加简洁了

CMake做什么?

CMake并不直接建构出最终的软件,而是产生标准的建构档(如Unix的Makefile或Windows Visual C++的projects/workspaces),然后再依一般的建构方式使用。

说白了,CMake是在已有的平台编译工具链上加了一层抽象,自己造了新的构建语言DSL,用来翻译到目标工具链上

用CMake主要解决跨平台编译的问题

  1. CMake比原生的makefile,vcproj要容易编写维护
  2. 大家都用CMake,这样第三方库依赖也容易管理,都是用同一种语言CMake沟通

编译器做什么?

cmake只是在工具链上封装一层,本质还是编译器在起作用

重新梳理了编译概念后,我解开了几个误区,结论如下:

编译的本质,是一些target,每个target有一些属性,target之间有依赖关系,编译完每个target后,再链接到exe

所以ZeloEngine可以这样改进:

  1. 用monolithic构建成单一库,全部静态库构建,KISS // 后续扩展成插件架构,走动态库
  2. 内部target其实不需要拆的很细,分层即可,静态库可以互相依赖,最后都是复制粘贴到exe中
  3. cmake脚本声明好每个target的属性即可,声明没有顺序依赖,不需要考虑add_subdirectory的顺序

静态库 vs 动态库

静态库

  1. archive of exe code
  2. 静态链接,链接时决议依赖
  3. 链接时,linker,依赖直接拷贝代码,类似#include
  4. 运行时,无依赖,只有一个大exe

动态库

  1. dynamic archive of exe code
  2. 动态链接,运行时决议依赖
  3. 运行时,OS,依赖通过指针引用代码
  4. 所有依赖A的程序,都引用同一块A代码内存

静态库优势

  1. 略微性能提升,因为没有overhead
  2. 无兼容性问题,编译时粘到一起了

静态库劣势

  1. 体积大,因为拷贝
  2. 静态库A更新,依赖A的target也要重新编译
    a. 编译的传递依赖,增加编译时间

动态库优势

  1. 体积小,内存占用小,多个毫不相关的进程可以共享同一份动态库内存
  2. 保证兼容的情况下,可以替换掉动态库,无需编译其他程序

动态库劣势

  1. 兼容性
  2. DLL HELL

构建依赖解决了什么问题?

把引擎当作一个黑盒,它引用了一堆第三方库

所以理论上,搞一个Engine.exe,把所有代码放在一起,然后引用所有第三方库,就可以了

那么拆分依赖有什么用呢?

  1. 复用
  2. GC
  3. 并行化

一个大的引擎中,有一些小模块是可以复用的,有一些模块随着时间推移不再被引用,可以被删除

并行度与依赖反相关,分层设计,模块化,降低图的深度
在这里插入图片描述

前面提到引擎内部target其实不需要拆,是因为互相依赖很强,而且因为体量小,一个人开发,完全清楚使用情况

依赖与self-contained

想要利用好依赖,就要构造self-contained依赖图

self-contained这个词,来自于#include依赖关系

在构建系统中,扩展为构建依赖关系,包含#include依赖和链接依赖

只需要做到:依赖所有自己直接需要的库

我们在解决什么问题?

本质上,是为引擎定制一个C++构建系统

以Unreal为例,大型游戏引擎的专用构建系统需求:

  1. 全平台构建,PS4,安卓,IOS,Switch
  2. 替换掉cmake,简化,非黑盒,可扩展新平台

说实话,cmake确实没有那么好用,曾经尝试过自己写zmake,效果很差,垃圾

ZeloEngine的体量处在一个尴尬点,并没有那么小,随便写写不考虑通用性;也没有那么大,我有足够的成本去维护一个专用的构建系统换掉CMake

ZeloEngine的需求:

  1. 不需要全平台,win够了
  2. 根据体量做一个权衡的设计,不要过于复杂,但是可扩展,通用,好用
  3. 理解编译器到底在做什么,不要停留在cmake脚本层面
  4. 第三方库管理
  5. 引入modern cmake
  6. 引入private依赖,拆分interface库和module

目录结构与构建

大型引擎项目的目录结构也是架构设计的一部分

目录结构和构建系统的关系

  1. 一个target,一个CMakeLists.txt,一个目录
  2. 目录体现分层和模块化设计

在这里插入图片描述

引擎分层与构建

引擎架构中,按bottom-up的分层顺序:

  1. Engine,引擎层
    a. Base,引擎无关层,C++语言补丁
    b. Core,引擎抽象层,平台无关
    c. Driver,渲染驱动实现
    d. Root,引擎根节点
    e. Main,引擎入口
  2. Sandbox,Demo的C++部分
  3. LuaBind,脚本绑定
  4. Script,Lua脚本,Demo的Lua部分

关于LuaBind

LuaBind会反向被Engine引用,Engine在初始化时调用LuaBind_Main来加载脚本绑定

但是这并不是循环引用,因为Engine是private引用LuaBind,可以理解为弱引用(weak_ptr)

// 这个问题困扰了我很长一段时间,为啥循环引用可以正常工作,其实没有循环引用

关于Main

Main.exe只是一个平台入口,定位类似EAMain,核心功能由引擎库提供

// electronicarts/EAMain: EAMain provides a multi-platform entry point used for platforms that don’t support console output, return codes and command-line arguments.

引入modern CMake

PS 本节只摘选和本文相关的内容,更深入分析请参考《参考》

传统的cmake是过程式的,所有调用都是全局影响的,主要靠目录结构递归来传递
cmake隐式维护一个全局状态,比如include-dir会push状态,离开目录时pop
依赖传递需要手工指定

modern cmake是声明式的,面向对象的,我定义一些target,每个target有一些构建属性,包含target之间的依赖关系,这些target定义之间是没有顺序依赖的,也不应该有顺序依赖(self-contained)
在这里插入图片描述

引入private依赖,拆分interface库和module

原来:一个子模块就是一个静态库,还会和其他子模块互相引用

现在:

  1. 子模块合并成module,每个module对应一个引擎分层
  2. 子模块变成interface库,只描述构建属性
  3. module会引用原来的子模块,来包含原来的构建属性

构建依赖图

cmake config阶段就可以分析出依赖了,不需要编译,因为cmake脚本描述了构建依赖关系

生成构建依赖图

// cd build
cmake .. --graphviz=test.dot
dot -Tpng test.dot -o out.png

图例如下,图例中需要注意的:

  1. 动态库,静态库,interface库
  2. public,interface,private依赖
    在这里插入图片描述

cmake依赖图的不足:没有体现分层,第三方库二分图

这个问题不大,没有也够用了

第三方库管理

第三方库和自己写的引擎库是一个二分图,引擎依赖第三方库

最原始的cmake方法,是find_package脚本,一般find脚本cmake或者第三方库会写好,自己写也很容易

ZeloEngine为了降低成本,用一种混合方式管理第三方库,按优先级顺序:

  1. vcpkg,管理有名的库
  2. vcpkg没有的库,用bootstrap下载源码,从源码构建
  3. 需要魔改源码的库,手工维护

// bootstrape是一个脚本工具,读配置文件下载源码,提供了一定的下载管理功能,避免重复下载,并且支持下载文件等

自动化

第三方库的管理需要自动化:

  1. 自动下载特定版本的库/源码
  2. 自动导出和读取依赖清单requirements.txt,包含程序所依赖的库列表和版本号约束 // C++没有,自己写个解析cmake脚本

一些特定需求举例

  • imgui,需要docking分支,vcpkg只能装最新master分支
  • lua和sol,需要lua5.1,sol会优先找vcpkg的lua5.4
  • optick,vcpkg没有

展望

  1. 依赖图可以进一步自动优化构建脚本吗?
  2. 插件架构,将和引擎库不会互相依赖的非核心部分作为插件拆分出来,利用动态链接的优势
    1. 优化链接时间
    2. 优化编译时间,修改插件不触发引擎核心重新编译
    3. 按需加载插件
    4. 减少二进制体积

参考

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值