資源管理器之前經過一次內部重構(但接口不變,所以代價相對比較低). 到目前還算穩定. 所以想把基本思路記錄一下, 留着備忘. 因為整個架構的代碼太多, 也積累了兩三年, 有時候為了修bug, 要花長時間再從頭閱讀自己以前寫的代碼, 或許有一個備忘的話會好一點.
資源管理器的訪問方式, 可以是singleton, 也可以是多實例, 印象OGRE的資源管理器是多個實例, 比如TextureManger繼承自ResourceManager, MaterialManager也繼承自ResourceManager, 由於時間長了, 可能記得不准確了. 我這里用的是全局唯一的singleton. 使用預定義的接口來完成資源加載流程.
抽象與解耦
資源管理應該關心哪些內容, 不應該關心哪些內容?
先舉個現實中的例子吧, 比如人力資源(HR), 要其他處理部門的人力請求. 而HR需要關心的是, 當前某個資源的基本屬性(National ID等等),還有狀態(recruiting, in position, resigning), 至於該資源的專業技能如何, 或許只需要知道結果, 不用關心技術面試的細節. 那是對應技術部門(程序,策划,美術)的問題. 否則, 如果HR想要關心這些細節, 就要求一個HR的工作人員又要精通程序又要精通美術,還要精通策划, 這明顯是不合理的.
所以HR主要負責人力請求和組織招聘流程, 與具體部門的技術細節無關.
同樣, 一個資源管理系統, 理論上也只應該負責組織加載流程, 而與具體的資源內容和加載細節無關, 只關心資源的類型,ID,狀態等等基本屬性, 否則這個資源管理系統就會變得錯綜復雜.
1.需求
首先考慮以下問題:
1.如何索引資源, 並避免資源被重復加載;
2.同一種類型的資源, 是否支持多種格式;
3.資源的異步加載;
4.GPU的渲染資源如何處理;
5.如何實現資源的無縫重新加載: 資源被重新加載以后, 不需要額外操作, 立即生效 (對編輯器,和調試很有幫助, 比如shader/貼圖等的測試);
6.統一的資源文件格式;
7.資源的加載緩沖怎么處理;
8.資源的加載流程/過程, 如何統一管理和抽象;
9.如何擴展資源格式;
10.如何保證最高的加載效率, 同時保證前台的流暢運行.
1.資源索引表一般使用resource id - resource map, 如果請求某個id的時候, 查找現有表數據, 如果存在則直接返回, 否則發起請求. 一般如果資源較少的時候可以用string map, 資源過多的時候可能需要使用hash. 目前Blade使用的是最簡單的string map.
2.理論上一種資源可以有n種格式.不管有幾種格式, 都只對應一種runtime的資源類型. 比如shader資源, 可以是源代碼, 也可以是編譯好的binary, 再比如一個場景/關卡描述文件, 可以是binary, 也可以是xml.
3.現代CPU基本沒有單核的了, 所以多線程是必須要支持的. Blade的Resource Manager默認加載方式是異步加載, 另外也提供同步加載模式.
但是線程/工作流的抽象是另外一個話題. 為了將資源管理同線程解耦, 資源管理模塊不需要關心線程的實現細節, 只考慮數據如何同步, 並使用最低的線程優先級. 比如Blade Framework里定義了ITask 和 ITaskManager, ITask是可擴展的預定義接口, resourcemanager會產生一個LoadingTask : ITask. 用戶可以將這個任務對象交給ITaskManager, 也可以自己處理, 一般來說交給ITaskManger就可以了.
同時, resource manager需要能夠靈活的處理任何異步請求. 即一個運行環境沒有所謂的"主線程","后台線程"之分, 而是有n個並行的線程, 每個線程都可以在任何時候請求一個資源.
4.GPU資源是資源的一種, 通常需要先進行IO操作, 然后upload 到GPU. 由於upload過程和GPU的渲染不能完全並行, 所以在IO以后要有同步機制. 由於Blade的每一幀都有同步運行的時間, 所以使用的是異步IO, 然后在同步模式下上傳顯卡資源. 需要注意的是, 紋理是可以精確到LOD(mipmap)來加載的, 這樣可以節省很多顯存/內存. 目前還沒有實現, 但是據了解國外的很多引擎都有這一功能.
5.不直接訪問資源對象, 而是通過二次轉換訪問.比如使用pointer to pointer(指向指針的指針: Handle的一個含義), 或者類似的二次封裝.
6.Gamebryo使用的文件格式, 其中有一個是根據RTTI類型描述, 運行時創建出對象. 當然具體實現要更復雜, 比如各個對象之間的關系, 要在所有對象創建好以后再處理等等. 之前考慮過類似的格式, 但是具體的資源, 后續的處理可能很不一樣, 所以這個沒有真正去實現. 但是場景文件基本可以用固定的方式加載擴展資源, 原理同Gamebryo的類似, 當然實現差別比較大.
7.由於定義了最簡單基本的接口, 用於后續的擴展. 所以沒有考慮統一的緩沖. 而且更重要的是, 系統已經有一個Temorary Memory Pool, 而且有對應的IOBuffer封裝, 任何資源的擴展都可以直接使用它, 比如直接把文件全部讀取到IOBuffer中, 或者使用流式讀取, 兩種方式都可以, 這取決於用戶, 根據具體情況(文件有多大), 選擇需要的實現策略.
8.統一的加載流程, 需要預定義完善的接口, 當然沒有絕對的完善, 可能需要在使用中提出需求並不斷完善接口抽象. 目前接口包括了兩部分"資源"和"資源序列化器", 這個后面介紹.
9.理論上, 擴展格式(添加一個新類型的資源) 不應該修改現有的資源管理器代碼. 這一點也通過預定義的接口和機制實現了, 后面也會介紹.
10.資源加載任務優先級最好比較低, 比如設置成最低. 同時, 資源的加載有time out, 超時后不再繼續處理資源請求隊列, 等下一幀再處理. 另外, 使用lock free模式可以提高異步的效率. lock free其實就是用CAS(comprae and swap)指令完成一個原子操作.由於原子操作同步時間很短,所以不需要重量級的lock. 其實Blade的lock實現也是CAS,已經支持lock free了,而且大部分情況都是這么用, 檢測鎖而不是直接加鎖. 不過真正的lock free通常有對應的容器版本實現, 比如lock free list 等等. 但是lock free不是wait free, CAS還是有等待的. 個人覺得應該在整體框架上避免過多的等待.
另外好像是Game Engine Architecture上有一種直接讀寫對象的方法, 原理大致如下:
struct B { .... }; struct A { int32 size; int32 data; struct B* link; uint32 count; //count of objects of struct B }; //內存中的layout, 需要在A后面緊接着就是B, 這個可以在運行時構造出來. 在寫入文件的時候, 將B的絕對指針改寫為相對於struct A起始的offset. //比如 uint count = 10; size_t bytes = sizeof(A) + sizeof(B)*count; A* a = (A*)malloc( bytes ); a->size = bytes; a->link[...] = ...; .... ; //init/use/modify the data (uintptr_t&)(a->link) -= (uintptr_t)a; //get an offset and save it write(a, a->size); //讀取的時候, 把對象的起始地址加上offset, 就得到了真正的對象地址. int32 size; read(&size); struct a* a = malloc( size ); seek(CURRENT, -(int)sizeof(size) ); read(a, size); (uintptr_t&)(a->link) += (uintptr_t)a;
雖然這么處理速度很快, 但是比較復雜, 因為用到了指針, 而且去讀后要求直接可用, 要處理對齊等等因素. 所以這種方式寫入的數據多數跟平台相關(機器字長, alignment等等), 總的來說可能不好抽象,所以Blade的框架沒有提供對這種方式的基礎支持和抽象, 但它確實是一個不錯的思路. 但是用戶完全可以自己這么做, 但是這將會把移植的問題交給用戶..(要考慮是否移植, 以及如何處理). 當然目前只有我自己是用戶, 但是開發者作為自己的用戶時, 還是應該從用戶的角度考慮問題.
2.加載流程
預定義的資源對象和資源加載器接口如下:
class IResource { public: class IListener { public: virtual ~IListener() {} /* @describe @param @return */ virtual void preLoad() {} /* @describe @param resource maybe NULL when loading failed @return */ virtual void postLoad(const HRESOURCE& resource) { } /* @describe when loading succeed @param @return */ virtual void onReady() {} /* @describe when loading failed @param @return */ virtual void onFailed() {} /* @describe @param @return */ virtual void onUnload() {} };//class Listener public: virtual ~IResource() {} /* @brief */ inline const TString& getSource() const {return mSource;} /* @brief */ inline bool isValid() const {return mValid;} /* @describe @param @return */ virtual const TString& getType() const = 0; protected: /* @brief */ inline void setSource(const TString& source) {mSource = source;} /* @brief */ inline void setValid(bool valid) {mValid = valid;} TString mSource; bool mValid; friend class ResourceManager; };//class IResource
class ISerializer { public: virtual ~ISerializer() {} /* @describe this method will be called in current task or background loading task,\n and the serializer should NOT care about in which thread it is executed(better to be thread safe always). and IO related operations need to be put here. @param @return */ virtual bool loadResource(IResource* resource, const HSTREAM& stream, const TParamList& params) = 0; /* @describe this method is lately added to solve sub resource problem : whether load synchronously or not. commonly, you don't need to override this method, it's used by framework. @param @return */ virtual bool loadResourceSync(IResource* resource, const HSTREAM& stream, const TParamList& params) { return this->loadResource(resource, stream, params); } /* @describe check whether the serializer finished loading the resource (before post-process) commonly one serlializer is done loading after loadResource(). but if some cascade resource contains linkage to other sub resources, then it is not done until the sub resources is loaded and this method is right for the cascade resource type. @param @return */ virtual bool isloaded() const {return true;} /* @describe load resource in main synchronous state,if success,return true \n and then the resource manager will not load it again in background loading task. @param @return */ virtual bool preLoadResource(IResource* /*resource*/) {return true;} /* @describe process resource.like preLoadResource, this will be called in main synchronous state.\n i.e.TextureResource need to be loaded into video card. the difference between this and loadResource() is: loadResource mainly perform IO related process and maybe time consuming. this method mostly process on memory data, especially memory data that need be processed synchronously, and it need to be fast. @param @return */ virtual void postProcessResource(IResource* resource) = 0; /* @describe @param @return */ virtual bool saveResource(const IResource* resource, const HSTREAM& stream) = 0; /* @describe @param @return */ virtual bool createResource(IResource* resource, TParamList& params) = 0; /* @describe this method is called when resource is reloaded, the serializer hold responsibility to cache the loaded data for resource, then in main thread ISerializer::reprocessResource() is called to fill the existing resource with new data.\n this mechanism is used for reloading existing resource for multi-thread, the existing resource is updated in main thread(synchronizing state), to ensure that the data is changed only when it is not used in another thread. like the loadResouce,this method will be called in main thread or background loading thread,\n and the serializer should NOT care about in which thread it is executed. this is the "reload" version of loadResource() @param @return */ virtual bool recacheResource(const HSTREAM& stream, const TParamList& params) = 0; /* @describe this method is lately added to solve sub resource problem : whether load synchronously or not. commonly, you don't need to override this method, it's used by framework. @param @return */ virtual bool recacheResourceSync(const HSTREAM& stream, const TParamList& params) { return this->recacheResource(stream, params); } /* @describe this method will be called in main thread (synchronous thread), after the ISerializer::recacheResource called in asynchronous state. this is the "reload" version of postProcessResource() @param @return */ virtual bool reprocessResource(IResource* resource) = 0; };//class ISerializer
IResource的抽象很簡單, 基本上只有ID(full path)和TYPE, 這個是最小接口, 用於resource manager管理.
ISerializer主要包括3功能. 加載, 保存, 創建. 加載分兩個步驟: IO和后處理, 對應loadResource() 和postProcessResource. 基本IO和加載在后台被調用, 而后處理用於在同步模式下的處理,比如GPU資源的upload.
資源管理器的典型加載請求處理流程如下:
////////////////////////////////////////////////////////////////////////// bool ResourceManager::loadResource(const TString& resType,const TString& path, IResource::IListener* listener/* = NULL*/,const TString& serialType/* = TString::EMPTY*/, const TParamList* params/* = NULL*/) { int loadMethod = RLM_ASYN; HRESOURCE hRes; //check if the resource is already loaded ResourceTypeGroup::iterator i = mLoadedResources.find(resType); if( i != mLoadedResources.end() ) { ResourceGroup& group = i->second; ResourceGroup::iterator n = group.find(path); if( n != group.end() ) { hRes = n->second; assert( hRes != NULL ); //been unloaded, so RefCount is 1 if( hRes.refcount() == 1 ) { //reload resource loadMethod |= RLM_RELOAD; } else { if(listener != NULL ) { listener->postLoad(hRes); listener->onReady(); } return true; } } } //check if the resource is being loaded now if( mListeners.check(resType, path, listener) ) return true; if( hRes == NULL )//load { IResource* resource = BLADE_FACOTRY_CREATE(IResource,resType); resource->setSource( path ); hRes.bind( resource ); } else { //load unloaded resource, reloading } TString ResourceSerializerType = serialType; if( ResourceSerializerType == TString::EMPTY ) ResourceSerializerType = resType; ISerializer* resLoader = BLADE_FACOTRY_CREATE(ISerializer, ResourceSerializerType ); return ADD_TO_TASK_QUEUE(hRes, resLoader, listener, params, loadMethod); }
其中, resType是資源類型, 即IReosurce的工廠注冊類名, 同樣serialType是加載器對應的factory class name.
params 是加載選項, 是一個string-variant map, 比如對於一個紋理資源, 可指定最終格式(是否壓縮), mipmap級別等等.
listener是用於異步加載時的事件通知, 就是在資源加載完畢之后的回調, 告訴用戶該資源已經加載完畢.
主要的加載流程如下:
1. 檢測資源是否已經加載. 如果已經加載直接返回.
2. 檢測資源是否正在被加載(在隊列中), 如果在, 則將listener加入該資源的listener列表.
3.根據資源文件的擴展名/顯式指定的資源類型, 以及序列化類型, 創建出對應的對象
4.將對象放入loading task 的隊列.
5. loading task在執行時, 會創建出stream, 調用ISerializer的loadResource() 或者是recacheResource() (recache跟load基本一樣, 是reload版本對應的IO), 完成IO. 完成后放入就緒隊列
6.在同步模式下, 資源管理器會對資源進行后處理,ISerializer::postProcess(), 完成比如GPU資源的upload, 之后該資源正式變為可用資源, 並派發資源就緒的event到所有的listener.
這里需要注意的問題, 由於listener是異步請求的監聽對象, 但是等資源真正加載完成的時候, 這個listener可以已經不存在了, 需要特別處理. 但基本的要求的是, listener在一次資源請求結束之前,必須是有效的.
3.擴展性
在資源管理系統設計之初,還沒有任何具體的資源和對應的格式, 所以選擇了抽象接口的方式, 用於以后擴展.
資源管理器的擴展性主要在於使用類工廠.
Blade的類工廠是DLL導出類, 一個接口可以對應一個工廠, 與經典的GOF Abstract Factory pattern不同的是, Factory本身不需要再抽象和被實現(太繁瑣), 但是允許創建多種實例. 擴展時, 用戶代碼往工廠添加注冊信息: 標識(字符串)和創建方法, 這樣后面就可以根據該標識創建出擴展的對象了. 至於如何實現類工廠, 這里暫不討論, 只記錄一下類工廠對資源系統帶來的便利.
類工廠可以在運行時,根據類型創建對象, 是一種類型反射. 資源文件的數據中保存一個類型信息, 然后根據類型信息, 在類型工廠中創建出對應的實例. 比如場景文件里面保存了所有對象和對象的類型信息, 在加載場景的時候, 會根據該信息, 使用工廠創建出具體的對象, 這樣場景的加載過程就變得流程化,標准化了. 這個跟Gamebryo的RTTI的序列化類似.
說道RTTI, 人不太喜歡RTTI (Effective C++如是說), 因為依賴類型信息的編程往往是面向過程的, 難免有各種if和switch case, 比如Gamebryo里面的IsKindOf判斷等等, 要根據類型做條件分支. 從某個角度來說上來說OO的思想是用動態綁定替代掉if和switch case, 利用統一的接口寫出通用的算法, 而特化的部分留給具體的類實現, 保持已有代碼的穩定性. 當然GB里RTTI的序列化是個特例, 它比較靈活, 不算是面向過程的, 不過C++標准的RTTI沒有這個功能, 這個是GB自己實現的. Blade沒有自定義的RTTI, 唯一用到C++標准RTTI的地方是數據綁定.
比如圖形子系統插件的初始化, 注冊資源是這樣注冊的:
NameRegisterFactory(TextureResource,IResource,TEXTURE_RESOURCE_TYPE);
NameRegisterFactory(Texture2DSerializer,ISerializer,TEXTURE_2D_SERIALIZER);
NameRegisterFactory(Texture3DSerializer,ISerializer,TEXTURE_3D_SERIALIZER);
NameRegisterFactory(TextureCubeSerializer,ISerializer,TEXTURE_CUBE_SERIALIZER);
NameRegisterFactory(Texture2DSerializer,ISerializer,TEXTURE_RESOURCE_TYPE);
//only support 2 file formats:
IResourceManager::getSingleton().registerFileExtension( TEXTURE_RESOURCE_TYPE, BTString("dds") );
IResourceManager::getSingleton().registerFileExtension( TEXTURE_RESOURCE_TYPE, BTString("png") );
IResourceManager::getSingleton().addSearchPath( TEXTURE_RESOURCE_TYPE, BTString("image:/") );
NameRegisterFactory(VertexShaderResource,IResource,VERTEX_SHADER_RESOURCE_TYPE);
NameRegisterFactory(FragmentShaderResource,IResource,FRAGMENT_SHADER_RESOURCE_TYPE);
NameRegisterFactory(GeometryShaderResource,IResource,GEOMETRY_SHADER_RESOURCE_TYPE);
NameRegisterFactory(BinaryVertexShaderSerializer,ISerializer,VERTEX_SHADER_RESOURCE_TYPE);
NameRegisterFactory(BinaryFragmentShaderSerializer,ISerializer,FRAGMENT_SHADER_RESOURCE_TYPE);
NameRegisterFactory(BinaryGeometryShaderSerializer,ISerializer,GEOMETRY_SHADER_RESOURCE_TYPE);
NameRegisterFactory(VertexShaderSerializer,ISerializer,TEXT_VERTEX_SHADER_SERIALIZER);
NameRegisterFactory(FragmentShaderSerializer,ISerializer,TEXT_FRAGMENT_SHADER_SERIALIZER);
NameRegisterFactory(GeometryShaderSerializer,ISerializer,TEXT_GEOMETRY_SHADER_SERIALIZER);
可以看出, TextureResource對應多種Serializer, 有普通的, 3D(volume), 還有Cube. 只要注冊了資源和對應的加載器,
那么運行時就可以指定資源類型和 加載器類型, 去加載對應的資源, 比如 IResourceManager::getSingleton().loadResource( "TextureResourceType", "media:/image/empty.dds", listener, "CubeTexSerializer");
同樣shader也有多種格式: 源代碼格式和二進制格式. 而Blade的shader compiler所做的工作非常簡單, 就是把源代碼(如HLSL)的shader載入系統, 然后使用binary serializer保存該shader.
總的來說, 支持有多種serializer使架構更加靈活和易用. 再比如shader的加載,對於GLES的GLSL, 可以選擇使用源代碼在線編譯, 而對於D3D則離線編譯. 雖然不依賴於架構的實現也不難, 但是有了這樣的架構, 做起來或許會更簡單. 但是要注意架構往往伴隨着約束性和適用性, 所以根據具體情況來說, 也未必一定是好事.
如果考慮資源升級的話, 可以考慮VersionFactory, 用於指定版本和類型創建加載器, 當前version被寫入資源, 加載的時候根據version來創建對應的加載器. 保存的時候默認使用最新的版本.
這樣的話, 資源的無縫升級理論上可以實現, 但目前還沒有實際測試和使用. 而且資源升級工具也變得簡單, 同樣是打開文件, 然后保存. 等確保所有資源都升級完畢以后, 可以刪除舊的serializer代碼或者先不注冊該類, 禁用掉, 保留一段時間后確保穩定了再去掉, 當然一直放着不去掉也沒關系, 因為一個版本的serializer理論上是一個整體, 而不像有些代碼, 只有一個serializer, 但是要加載很多種版本, 維護起來可能稍微有點亂.
4.包文件系統
由於Blade的IArhive借鑒了Ogre的IArhive, 所以基本接口比較類似. 同樣, 文件系統也用了類工廠的方法, 只要用戶寫出自己的包系統, 就可以方便的注冊進框架使用.
與resource類似, IArchive也支持根據擴展名來判斷Archive類型的方法:
IResource Manager:
virtual bool registerArchiveExtension(const TString& archiveType, const TString& extension) = 0;
比如內置的zip格式是這樣注冊的:
NameRegisterFactory(ZipArchive, IArchive, ZipArchive::ZIP_ARHIVE_TYPE);
IResourceManager::getSingleton().registerArchiveExtension( ZipArchive::ZIP_ARHIVE_TYPE, "zip" );
這樣如果遇到類似"data.zip/textures/terrain01.dds" 諸如此類的最終路徑, resourcemanager會根據路徑中的擴展名, 判斷對應的包格式, 並選擇對應的Archive.
5.其他
資源管理器有一個用於初始化的配置文件, 文件中記錄了URI的protocol映射, 比如:
model = media:model //image = media:image is also OK image = media:/image character = model:/character building = model:building shader = media:/material/shader/dx9
本來根目錄media也在配置文件里, 但改為使用代碼手動設置了. 主要是配置文件放在數據包里面(用戶不能更改), 而在做android移植時, 數據包就是media根路徑, 而定義media路徑的配置文件本身就放在media里, 導致循環依賴, 需要先手動給出定義media://, 比如 media = /sdcard0/Android/data/com.yourapp/files/data.bpk
目前Blade使用了2個預定義的路徑, cwd:/ 即為程序啟動時的所在文件夾, media:/為數據的根路徑. 一個是自動初始化的, 一個需要用戶指定.
另外, 只有IResource/ISerializer還不能滿足需求, 如資源的級聯加載(一個資源包含了子資源). 比如一個material所包含的pass里的所有的shaders/textures等等所有就緒后, 這個material才算完全加載完畢, 這種事件通知是resource manager無法處理的, 它只能處理單個資源的就緒event. 所以Blade framework 還提供了ResourceState, 供用戶使用, 用來管理資源狀態和級聯的資源加載/卸載. 這個后面有時間的話, 也總結記錄一下.