zoukankan      html  css  js  c++  java
  • 引擎设计跟踪(九.11) 资源管理设计备忘

    资源管理器之前经过一次内部重构(但接口不变,所以代价相对比较低). 到目前还算稳定. 所以想把基本思路记录一下, 留着备忘. 因为整个架构的代码太多, 也积累了两三年, 有时候为了修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,
    
            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 
    
            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.
    
            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.
    
            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,
    
            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, 供用户使用, 用来管理资源状态和级联的资源加载/卸载. 这个后面有时间的话, 也总结记录一下.

  • 相关阅读:
    mysql导入导出数据过大命令
    thinkphp条件查询
    php表单提交安全方法
    ubuntu软件(查看文件差异)
    thinkphp if标签
    thinkphp导出报表
    jquery.easing.js下载地址
    水平手风琴切换效果插件亲自试过很好用
    li ie6/7 3px bug
    placeholder兼容IE6-9代码
  • 原文地址:https://www.cnblogs.com/crazii/p/3597661.html
Copyright © 2011-2022 走看看