《Game Engine Architecture》是一直以来我很有兴趣也很想阅读的书,书的译者是我很敬重的milo叶劲峰前辈(魔方引擎中心的大大)。
但是由于自己基础不牢,再加上这本厚厚的大书所带来的畏难情绪,同时自己过于功利总是把时间抽去做容易提升的事情,
以及n个自己给自己找的理由,让这个阅读计划一直搁置。
但这学期拿到了暑期实习的offer,刚巧也在魔方,对于就业的压力就减小了很多
再加上自己有意往引擎方向发展,阅读这本书就提上了日程。
今天进104官网看,发现引擎架构理所当然成为了这门课的官方教科书
这本书确实聊的蛮浅,但本人毕竟也才疏学浅看完这本书也需要些精力
今天晚上104就开课了,感觉自己进度好慢什么都跟不上
虽然是有点急了但是我菜的离谱也是当务之急,赶紧多肝吧
今天保底把第六章搞完,有机会的话肝肝第七章
资源及文件系统
游戏引擎必须有载入和管理多种媒体的能力,包括texture,mesh,animation,sound,collision,physics,scene
这涉及到如何同步或者异步IO(串流载入)的问题,涉及到如何跨平台编写文件系统,
涉及到如何管理runtime的资产占用的内存,涉及到怎么保存交叉引用的复合对象
(今天就只记得看了这么多了,真的很摸鱼)
6.1 文件系统
6.1.1 文件名和路径
路径是描述了文件系统层次中文件或目录位置的字符串
在windows上(不讨论其他平台了),路径是一般是c:\Windows\System32这样的
路径分为绝对路径和相对路径,(有机会可以提提在UE里面导资产的事情)
在运行时寻找资产是十分费时的,最好在运气前就搜寻完所有的资产
若想开发跨平台的游戏引擎,则需要实现一个轻量化的路径处理API而非直接使用平台API
6.1.2 文件I/O
一般会建议引擎将文件IO的API封装成自己的API,
好处是可以保证所有平台上都有相同的表现,可以降低维护量同时也能满足拓展的需求
我们一般会推荐使用异步文件IO API,因为如果使用同步的IO API
那么这意味着在所有数据读取完之前程序会进入阻塞,这明显是十分蛋疼的
我们希望游戏都有串流(背后加载数据,主程序继续持续运行)的功能
说起来圣莫尼卡的《战神》就把这一块玩的很好,全程基本不会黑屏读图
为了支持串流就必须使用异步的IO API,具体伪代码详见原书p278
多数异步IO库允许主程序在请求发出一段时间后,等待IO完成才继续允许
如果需要在等待IO时做些有限的工作,则这种方式十分适用。
有些IO库允许程序员取得某些异步操作的时间估计,并设置时限
以及超出时限的安排(取消请求,通知程序,继续尝试)
6.2 资源管理器
游戏资源必须被妥善处理,包括离线时将资产转化为引擎适用资产的离线工具
还有在runtime时载入,卸下,以及操作资源的工具。
6.2.1 离线资源管理以及工具链
资产的版本控制这方面因为没有和别人协同开发过
因此更不了解如何做好一个版本控制了,这对自己搓的玩具来说也不算大事,略过
对于大部分资产来说,游戏引擎不会使用原来的格式(就好像虚幻的uasset)
每个资产会流经资产调节管道(asset Conditioning Pipeline)
此时需要一些元数据(metadata,我悟了什么是元数据)来描述如何处理资产
元数据就是描述数据的数据,例如要压缩位图使用哪一种压缩方式
为了管理这些metadata以及资产,我们需要某种数据库。
数据库(也可以说是资产编辑器了)一般满足如下功能:
能处理多个资产,创建,删除,查看,修改,移动(磁盘上的位置)
交叉引用,维持交叉引用完整性,保存历史版本,多种搜索方式
UE4的unreal editor就是个很棒的例子,editor几乎负责一切事物
包括元数据管理,资产创建,关卡布局,
可以在创建资产时直接看到资产在游戏世界的样子(所见即所得)
还可以在Editor中直接运行游戏,以便观察其如何在游戏中运作
同时UE的引用完整性做的也相当好(读的知识越多越感觉ue做的叼)
顽皮狗和ogre的离线资产管理方案这里就不做总结了
资产调节管道也称(resource conditioning pipeline,名字不重要)
每个资产管道的始端都是原生格式的源资产,通常会经过三个处理阶段到达引擎
导出器(expoter)将原生格式的资产中的数据导出为中间格式供后续管道使用
编译器(compiler)将第一阶段的数据进行小改动,例如重新压缩纹理,并非所有资产都需要重新编译
链接器(linker)将多个文件结合成一个包再一起导入引擎,类比cpp的链接过程
6.2.2 运行时资产管理
runtime管理的许多责任和功能都和其主要功能(载入资源到内存)有关:
确保任何时候同一份资源只有一份副本,管理资源生命周期(需要时载入不需要时卸载)
处理符合资源(多个资源组成的资源),维护引用完整性,管理资源载入后的内存
载入资源后初始化资源,提供较为统一的api,尽可能支持异步
资源文件及目录组织,一般是树状的不太关心(没啥好写的)
游戏中所有资源必须有某种全局唯一标识符(GUID)常见的GUID就是资源的文件系统路径
这能很直观的反应他们硬盘上的物理路径且GUID因此不会重复
为了保证在任何时间载入内存的都只有一份副本
大部分资源管理器会使用某种形式的资源注册表记录已载入的资源
以键值对的集合(键为GUID值为指针)的方法则非常经典
资源被载入内存时限以其GUID为键,加载资源注册表字典,卸载资源时删除记录
游戏请求资源时就会用GUID寻找资源注册表,找得到就传指针
找不到就自动载入一个新的资源或者返回错误码
在runtime载入资源会对游戏的帧率造成非常大的影响,甚至是停顿
因此引擎可以采用串流(异步加载)或者是在游戏进行时完全禁止加载
每个资产对生命周期有不同的要求:
- 有些资产在游戏开始时必须被载入,驻留在内存一直到整个游戏结束,或者说其生命周期是无限的。典型例子有角色网格,材质,纹理以及核心动画,HUD,以及其他全程可以听到看到的资源
- 有些资产的生命周期则对应某个关卡,在玩家离开关卡时资源才被弃置(就好像ue的level资产,假设我们这里所说的关卡是一个实体而非逻辑概念的话)
- 有些资产的生命周期短于一个关卡的时间,例如过场动画,一小段BGM
- 有些资产的生命周期如很难定义,例如一些音乐和音效,因为每字节只短暂停留在内存中,这类资源通常以特定大小区块为单位载入。
在何时载入资源是已知的好解决的,但我们应该在何时卸载资源归还内存呢
许多资源依然会在之后的关卡继续共享,我们当然不希望将某些资源卸载后马上又加载他们
一个很好的方案就是使用引用计数(提到引用计数你应该想起智能指针类)
载入新的关卡时先遍历这些关卡所需的资源,并将其引用计数加1
然后再遍历即将卸载的资源将其引用计数减一,引用计数跌到0的就应该卸载掉。
载入资源是不可避免的问题是考虑资源加载到哪一块内存,之前所述的内存分配系统通常与资源管理系统有很大的关系
要么利用已有的内存分配器设计系统资源,要么就设计内存分配器以配合资源管理所需。
基于堆:若目标平台为PC,则由于操作系统支持高级的虚拟内存分配,这个方法还算勉强可以接受
但如果游戏运行在一个物理内存有限的游戏机上,只配上了最基础的虚拟内存管理器(可能还没有)
那么内存碎片就会是一个较为严重的问题,可以回顾之前所说的定期整理内存碎片的方案。
基于栈:因为栈的内存分配是连续的,因此不会有内存碎片的问题。若能确保游戏是线性以及以关卡为中心
且一次内存足够容纳一整个关卡,那么就可以用这个方法。我们甚至可以用堆载入资源
标记栈的堆顶位置,每次完成关卡后都重新将栈顶指针重新指回到开始标记的位置,这样就可以迅速的释放关卡的所有资源
而且永远不会导致内存碎片
基于池:在支持串流(异步加载)的游戏引擎中,一个常见分配技巧是将数据转化为同等大小的区块(chunk)。
因为chunk大小一致,因此就可以使用池分配器。但与此同时就得考虑分块的空间浪费问题
同时也要避免大型数据结构的使用,取而代之使用小于单个组块的数据结构
游戏的复合资源通常包含着大量交叉引用:A引用B,B引用CD,ABCD必须同时在内存才能运行
要完整的载入复合资源,就得载入其依赖的所有资源。在Cpp中交叉引用一般以指针实现。
一个好的方式是使用GUID做交叉引用,资源管理器维护一个全局资源查找表
每次将资源对象载入内存后,都要把其指针以GUID为键加入查找表中。
当所有资源对象都载入内存后,扫描一次所有对象,将其交叉引用资源对象的GUID通过查找表换成指针
有的资源在载入后还需要进行初始化,例如定义mesh的顶点和索引值,这些数据在渲染前得传送到缓存
而初始化的步骤又只能在runtime进行,包括建立顶点和索引缓冲,锁定缓冲读入缓冲以及解锁缓冲
在Cpp中许多开发者更喜欢把载入后初始化和卸载置于Init()Destory()这样的虚函数中
肝到了0点18分,没想到这一章节感觉东西不多但是居然肝了有3k字,太难以置信了