430 likes | 677 Views
切割管道. Niklas Frykholm. 实现亚秒级的迭代时间. Bitsquid 公司. 再次回到这里. 做出改变. 检查结果. 出口. 寻找实体. 汇编数据. 载入关卡. 重启游戏. 迭代运动循环. 分钟(小时)直到一个改变能够在游戏里看见. 为什么需要更快速的迭代时间?. 生产效率 等待构建所失去的时间 质量 更多的调整 在游戏机上游戏内测试的资产 注意:本次演讲是关于优化管线 延迟 而不是 吞吐量 更新单个不正确的资源所要求的时间. 《 汉密尔顿的大冒险 》 ( Hamilton ’ s Great Adventure ).
E N D
切割管道 Niklas Frykholm 实现亚秒级的迭代时间 Bitsquid公司
再次回到这里 做出改变 检查结果 出口 寻找实体 汇编数据 载入关卡 重启游戏 迭代运动循环 分钟(小时)直到一个改变能够在游戏里看见
为什么需要更快速的迭代时间? • 生产效率 等待构建所失去的时间 • 质量 更多的调整 在游戏机上游戏内测试的资产 • 注意:本次演讲是关于优化管线延迟而不是吞吐量 更新单个不正确的资源所要求的时间
《汉密尔顿的大冒险》(Hamilton’s Great Adventure) 50多个关卡
《玫瑰战争》 (War of the Roses) 《末日余痕》(Krater) 《星际虚空》(Starvoid) 《阿比逃亡记》高清版本 (Abe’s Oddysee HD)
雄心勃勃的目标 “立刻”看到改变(<1秒钟)
30赫兹 没有欺骗! 目标硬件 + 目标帧速率
再次回到这里 检查结果 做出改变 改变模型... 改变素材... 改变纹理... 新增刚体... 编辑脚本... 出口 发现实体 编译数据 载入关卡 重启游戏 实时编辑怎么样? 我们是否甚至需要一个管线?
实时编辑的问题 • 游戏并不总是最好的编辑器 • 如果游戏数据是生动的二进制图像,那么版本管理是很棘手的 协同工作和融合变化也是棘手的 • 跨平台?在PS3上编辑? X360? iOS? • 适合编辑的数据格式不具备最佳的运行时间性能
快速迭代: 两全其美 快速的游戏 • 二进制资源 • 就地载入 • 无需寻找时间 快速的工作流程 • 较短的编译时间 • 热重载 • 立即的反馈
再次回到这里 做出改变 检查结果 出口 寻找实体 汇编数据 载入关卡 重启游戏 替换 这些 重载 加速这一项 进攻策略 尽可能快的进行编译并使用重载代替重启
分而治之 • 重新编译和重新加载所有数据(>1 GB)永远不可能足够快 • 我们必须使用更小的模块来工作 把游戏数据当成是一些个别资源的一个集合,这些资源每个都能够单独进行编译,接着在游戏运行时重新加载
类型: 名称: 源文件: 纹理 纹理/植物/草地 纹理/植物/草地.texture 个别的资源 • 通过类型 + 名称进行识别 • 二者都是唯一的字符串标识符(进行散列处理) 名字来源于一个路径,但是我们把它当做一个标识ID (只能通过平等比较)
编译资源 • 每一个资源编译到一个特定平台的运行时间通过二进制大对象(blob)进行优化 • 通过名字散列进行识别 (数据编译) 草地.texture (游戏内的资源管理器) ff379215 ff379215
ff379215 ff379215 edf123b2 2345e791 b3d42677 edf123b2 123 2345e791 b3d42677 123 加载资源 • 资源被组合成包用来加载 • 资源包通过一个后台线程进行流式处理。 • 在开发过程当中,资源都被储存在由散列命名的单独文件当中。 • 在最终版本中,资源包里的文件被捆绑在一起用于线性加载 boss_level.package
> 重新加载纹理植物/草地 重新加载资源 • 运行游戏侦听TCP/IP端口 信息是JSON structs结构数据 • 来自我们的工具的典型命令 激活性能抬头显示设备HUD 显示调试线 LuaREPL命令 (读取-评价-打印-循环) 重新加载资源 • 也用于我们所有工具的可视化 > 重新加载纹理植物/草地
ff379215 ff379215 ff379215 ff379215 ff379215 ff379215 重新加载资源(详情) • 载入新的资源 • 基于类型通知游戏系统 指针指向旧的和新的资源 • 游戏系统决定做什么 删除实例(声音) 停止和启动实例(粒子) 保持实例,对它进行更新(纹理) • 破坏/卸载旧的资源 O
示例:资源的重新加载 if (type == unit_type) { for (unsigned j=0; j<app().worlds().size(); ++j) app().worlds()[j].reload_units(old_resource, new_resource); } void World::reload_units(UnitResource *old_ur, UnitResource *new_ur){ for (unsigned i=0; i<_units.size(); ++i) { if (_units[i]->resource() == old_ur) _units[i]->reload(new_ur); }} void Unit::reload(const UnitResource *ur){ Matrix4x4 m = _scene_graph.world(0); destroy_objects(); _resource = ur; create_objects(m);}
疑难问题 • 将数据配置到游戏机 • 处理大型资源 • 编译很慢的资源 • 重载代码
文件伺服器 问题:配置到游戏机 • 将数据配置到游戏机上可能会很缓慢 文件传输程序并不适用亚秒级的迭代 • 解决方法:运行电脑上的文件伺服器 –游戏机从那里读取文件 透明的文件系统后端 获得<路径> <数据>
问题:大型资源 • 非常大的资源(>100 MB)永远无法快速地进行编译和加载 • 找到合适的资源颗粒度 不要把所有的几何体放到单个文件中 把几何实体放到到不同的文件中 让关卡对象参照它所使用的实体
问题:很慢的资源 • 冗长的编译使得快速迭代不可能实现 光照贴图,navmeshes文件,等等。 • 将烘焙(baking)从编译中独立出来 烘焙始终是一个明确的步骤:“现在只做光照贴图”(编辑按钮) 烘焙过的数据被保存在资源数据里并检查到存储库 接着像往常一样编译(从原始的纹理到压缩平台)
问题:重载代码 • 要加载的最棘手的资源 • 四种代码 着色器(Cg着色器、高级着色器语言HLSL) 流程(可视化脚本) Lua脚本语言 C++语言 • 流程和着色器当做正常的资源 只是是二进制数据 流程脚本
原始版本 move move set_velocity set_velocity Actor = Actor or class() function Actor:move(dt) self.pos = self.pos + dt end 实时重载的Lua语言 确保在重载时,变化能够被应用到现有的执行者(Actor)类别。 如果没有这个,重载将创建一个新的执行者类别并且现有的执行者对象将不会看到代码改变。 self.pos = self.pos + dt Actor my_actor self.pos = self.pos + dt 更新 Actor = Actor or class() function Actor:move(dt) self.pos = self.pos + self.v * dt end Actor my_actor self.pos = self.pos + self.v * dt
重载代码: C++ • 工具支持“重启可执行程序( Restart Exe)” 可执行程序文件进行重新加载,但如果你仍然在相同的位置看到相同的对象,那么只要使用新的引擎代码 状态通过工具保持 • 无法达到<1秒的目标,但还是非常有用的 小型的可执行程序大小会有帮助
再次回到这里 做出改变 检查结果 出口 重新载入 编译数据 快速的编译
小技巧:使用分析程序,Luke 你的工具也想要一些表现出沉迷热爱的东西
启动Exe文件 扫描资源 依赖关系 重新编译 关闭 渐进式编译 • 找到上一次编译以来所有被修改过的资源数据 • 确定依赖于这些文件的运行时间数据 • 对需要的部分进行重新编译 • 重要的是这个过程是坚如磐石的 信任很难获得却很容易失去 “最安全的做法是进行一次完全编译”
启动Exe文件 扫描资源 依赖关系 重新编译 关闭 挑战:依赖关系 • 基本着色器资源(base.shader_source)包括常见着色器资源(common.shader_source) 如果common.shader_source改变就需要重新进行编译 • 没有读取每个文件我们要如何知道是否改变了? • 解决方法:一个编译数据库 从前一次运行储存信息 在启动时打开,在关闭时保存更新 • 当一个文件进行编译时,储存它的依赖关系到数据库当中。 通过跟踪 open_file()自动确定它们
挑战:二进制版本 • 如果纹理资源的二进制格式改变了,那么每一个纹理都需要进行编译 • 解决方法:重用数据库: 在数据库中储存每一个编译资源的二进制版本 在数据编译器中再度检查当前版本 如果有一个不匹配就重新编译 • 我们使用相同的代码库(甚至是相同的可执行程序exe文件)在数据编译器和运行时间上,所以二进制版本总是在sync系统中。
启动Exe文件 扫描资源 依赖关系 重新编译 关闭 仍然有许多开销用于编译单个文件 触摸盘, ~2秒 遍历整个源代码数来检查修改时间 触摸盘,与项目规模成正比,5-20秒 读取和保存数据库,~1秒 与修改过的文件数量成正比 好吧,这是需要解决的必要工作
启动Exe文件 编译伺服器 扫描资源 依赖关系 重新编译 关闭 启动 & 关闭 • 启动和关闭编译器过程需要花费数秒的时间 • 解决方法:重新使用这一流程! 作为伺服器运行 通过TCP/IP端口接收编译器请求 source = project dest = project_win32 platform = win32 result = success result = failure error = Animation ”run” used by state ”run_state” not found
启动Exe文件 扫描资源 依赖关系 重新编译 关闭 扫描资源 • 缓慢:检查每个项目文件的修改时间(mtime) • 脆弱:取决于日期 如果一个备份副本受到了恢复,我们可以有mtime(file) < mtime(dest) 在编写的dest很糟糕时就会崩溃 信任很重要:我们从未想要强制进行一次完全编译 foreach (file in source) dest = destination_file(file) if mtime(file) > mtime(dest) compile(file)
启动Exe文件 扫描资源 依赖关系 重新编译 关闭 想法:明确的编译列表 • 工具发送一个它想要重新编译的文件列表 • 工具持续跟踪那些应改变的文件 纹理编辑器知道所有用户改变过的纹理 • 快速 • 脆弱:在工具之外不起作用 svn/git/hg更新 在Photoshop里编辑纹理 在文本编辑器里编辑Lua文件
启动Exe文件 扫描资源 依赖关系 重新编译 关闭 解决方法:目录观察程序 • 在伺服器启动时进行一次完整的扫描 • 在最初的扫描之后,使用目录观察程序检测变化 ReadDirectoryChangesW(...) 命令 • 不需要进一步的扫描 • 使用数据库来避免脆弱性 从上一次成功的编译里储存修改时间(mtime)到数据库里 如果扫描时修改时间或者文件大小改变了–重新编译 如果目录观测程序通知我们有改变发生–重新编译
source = project dest = project_win32 platform = win32 require ”stuff” function f() print(”f”) end 3. 请求到达编译伺服器 1. 文件改变了 C 2. 用户按下编译按钮 4. 伺服器获知改变的文件 目录观察程序争用条件 我们不知道收到时间会有多长
source = project dest = project_win32 platform = win32 require ”stuff” function f() print(”f”) end 3. 请求到达编译伺服器。伺服器创建一个临时文件 1. 文件改变了 C 5. 伺服器获知新的临时文件 2. 用户按下编译按钮 4. 伺服器获知改变的文件 争用条件技巧 使用临时文件作为“栅栏”
启动Exe文件 扫描资源 依赖关系 重新编译 关闭 依赖关系 • 因为我们没有破坏进程,我们可以把依赖关系数据库保存在内存里 只需要在伺服器启动时从磁盘读取 • 我们可以保存数据库到磁盘作为背景进程 当我们要求进行一次重新编译时,我们不需要等待保存数据库 当编译器处于空闲状态时,数据库就会在稍后进行保存
最后的进程 启动Exe文件 • 磁盘访问只会在处理请求是下列项时发生: 编译修改过的文件 创建目录观察程序“栅栏”文件 • 否则一切都发生在内存当中 读取数据库 扫描资源 启动观察程序 启动伺服器 分析清秋 查找修改 依赖关系 编译 发送回复 保存数据库 关闭
结果 高兴的内容创造者!!!
通用规则 • 考虑资源颗粒度 为个别编译/重载选择合理的大小 • TCP/IP端口是你的朋友 倾向于通过网络访问磁盘来进行工作 把进程当成伺服器运行来避免启动时间 • 使用数据库 + 目录观察应用程序来跟踪文件的系统状态 数据库也可以在编译器运行之间缓存其他文件 保存在内存中,反映到背景磁盘上
www.bitsquid.se niklas.frykholm@bitsquid.se niklasfrykholm 有什么问题吗