第十五章:AssetManager工作流程

基本过程

jME3 使用Java I/O来读取资产数据,把整个过程分为三个步骤:

一、打开输入流

这一步通常会根据资源路径(URL)获得一个 InputStream 对象,程序可以通过这个对象来读取数据。输入流的来源可能多种多样,例如:

  • 文件路径 new FileInputStream("D:\MyGame\Save\savedata.dat")
  • 网络地址 socket.getInputStream();
  • 内存地址 new ByteArrayInputStream(data);
  • 等等..

不同来源的输入流,需要有不同的处理方法。在 jME3 中,这一步由不同类型的 AssetLocator 负责完成。例如,通过下面的代码可以让 AssetManager 从磁盘文件夹中加载资产。

assetManager.registerLocator("F:\Models", FileLocator.class);

当使用 assetManager 来加载路径为 Models/Monkey/monkey.j3o 的资产时,FileLocator 就会试图通过下面这种方式来打开输入流:

InputStream is = new FileInputStream("F:\Models\Models\Monkey\monkey.j3o");

AssetLocator 把输入流搞定后,具体如何读取数据,就会交给第二步来处理。

二、解析数据

游戏中会用到很多不同类型的多媒体资产,需要根据文件格式来解析。

例如jpeg、png、gif等图片文件,先要根据压缩算法对图像数据进行解压,然后恢复成实际的图像数据才能在游戏中使用。

例如fbx、blend、3ds等模型文件,其中的内容可能由上千个不同的数据结构组成,需要针对每一种结构来分别处理,最终得到正确的顶点、法线、纹理坐标等数据来使用。

这些资产数据解析后,一般要生成 jME3 能够识别的专有数据结构,然后才能使用。在 jME3 中,这一步由不同类型的 AssetLoader 负责完成。例如 TGALoader 负责解析 tga 图片,生成 Texture 对象;OBJLoader 负责解析 obj 模型,生成 Spatial 对象。

jME3实现了多种不同的 AssetLoader,通过下面的代码,可以让 AssetManager 记住文件格式与 AssetLoader 之间的对应关系。

assetManager.registerLoader(TGALoader.class, ".tga");
assetManager.registerLoader(DDSLoader.class, ".dds");
assetManager.registerLoader(OBJLoader.class, ".obj");
assetManager.registerLoader(MTLLoader.class, ".mtl");

AssetManager 会自动根据 文件后缀名 来判断数据格式,找到对应的 AssetLoader 来解析数据。

jME3 专门定义了 AssetKey 类,它的主要作用是保存资产的路径(URL),并分析其后缀名。例如:

AssetKey key = new AssetKey("Models/Monkey/monkey.j3o");
assert key.getName() == "Models/Monkey/monkey.j3o";
assert key.getFolder() == "Models/Monkey/";
assert key.getExtension() == "j3o";

等找到了正确的 AssetLoader ,解析完毕后,输入流就可以关掉了。

三、关闭输入流

这是最简单的,通常只需要调用 InputStream 的 close() 方法即可。

缓存

在进行游戏开发时,很多资产文件都会被重复使用。比如路人NPC的模型,在一个场景中可能会出现很多次。又比如噪声纹理,可能被当做“云”材质使用,也可能被当做“水”材质使用。如果每次使用某个资产时都要从I/O加载,那引擎的性能就太低了。一般的游戏引擎都会缓存游戏资产。

缓存通常是内存中的Hash表,以 <Key,Value> 的形式保存资产。 AssetManager 使用 HashMap 来缓存资产,并定义了 AssetKey 用来查找和缓存资产。

AssetManager 在打开输入流之前,先要创建一个 AssetKey,并使用它来查找缓存。由于资产的路径(URL)通常是唯一的,jME3默认使用资产的URL来创建 AssetKey。

AssetManager 以 AssetKey 对象作为参数,查找 HashMap 中的资产。结果分为两种:

  • 命中缓存:如果发现需要使用的是内存中已经存在的资产,那就直接在内存中 复制(克隆) 一份。这样就不需要走I/O流了,速度会快很多。
  • 未命中缓存:如果在HashMap中没有找到资产,那么就走I/O流程。数据解析完毕后,把AssetKey和资产都 存入HashMap中,等下次使用就可以直接加载了。

这个缓存机制可以极大地减少 I/O 操作,但也带来了一些负面影响:

  • 热更新失效:如果在程序外部修改了资源内容,希望游戏内能看到改变,但由于资产的URL没变,导致 AssetManager 使用了缓存而不是更新后的文件。
  • 垃圾回收失效:Java的内存回收是依赖于底层 GC 的。当 HashMap 中一直保存这对象的引用时,GC 永远不会回收此对象的内存。

这两个问都好解决, AssetManager 中提供了 deleteFromCache(AssetKey key) 方法,可以手动清除缓存;也可以通过 AssetKey 设置不同的缓存机制,这个后续的文章再详细介绍。

文件之间的关联

使用前面介绍的模式来加载资产,流程固然清晰,但也带来了其它问题。

假设 AssetLocator 接口和 AssetLoader 接口是这样定义的:

public interface AssetLocator {
    public void setRootPath(String rootPath);
    public InputStream locate(AssetKey key);
}

public interface AssetLocator {
    public Object load(InputStream in) throws IOException;
}

若 OBJLoader 正在加载一个 obj 文件,其中记录了材质文件(.mtl)的相对路径。必须要需要根据 .obj 文件的 URL 来计算 .mtl 文件的路径,否则就无法加载模型的材质。可是在上面设计的接口中,AssetLoader 只拿到了 InputStream ,而 InputStream 是不保存资源路径的。 这就导致无法加载 .mtl 文件。

这就像一个老板,分别交代甲和乙合作完成一件事,但是禁止甲和乙私下沟通。现在乙要做一件事,可这件事只有甲才知道怎么办,乙就懵逼了。

jME3的接口当然没有这种低级错误,它使用 AssetInfo 来解决了这个问题。解决方法很简单,就是把 AssetManagerAssetKeyInputStream 等东西都保存在 AssetInfo 对象中,然后一起传给 AssetLoader。

AssetInfo 的定义如下:

import java.io.InputStream;
public abstract class AssetInfo {
    protected AssetManager manager;
    protected AssetKey key;
    public AssetInfo(AssetManager manager, AssetKey key) {
        this.manager = manager;
        this.key = key;
    }
    public AssetKey getKey() { return key; }
    public AssetManager getManager() { return manager; }
    @Override
    public String toString(){
        return getClass().getName() + "[" + "key=" + key + "]";
    }
    public abstract InputStream openStream();
}

其中的主要方法如下:

  • openStream() 方法,返回 InputStream 对象,用于解析数据;
  • getKey() 方法,返回 AssetKey 对象,通过它就能够得到资产路径;
  • getManager() 方法,返回 AssetManager 对象,可以用它来加载相关的纹理、材质等资产。

jME3 中实际的 AssetLocator 和 AssetLoader 接口设计是这样的:

public interface AssetLocator {
    public void setRootPath(String rootPath);
    public AssetInfo locate(AssetManager manager, AssetKey key);
}

public interface AssetLocator {
    public Object load(AssetInfo info) throws IOException;
}

小结

经过前面的介绍,下面重新梳理一下AssetManager的工作流程。

具体的资源加载步骤如下:

  1. 根据资产的URL,创建AssetKey。
  2. 根据AssetKey去缓存中查找资源。
  3. 若AssetKey命中缓存,则复制资源内容,直接返回。
  4. 若AssetKey未命中,各种AssetLocator将去根目录下搜索对应的URL。若找不到资产,则抛出 AssetNotFoundException。
  5. 若AssetLocator找到资源,则打开输入流,输出AssetInfo对象。
  6. AssetLoader根据输入的AssetInfo,解析资源数据。
  7. 将解析结果保存到缓存。
  8. 关闭输入流。

需要注意的是,上述流程并不完整。在第6步和第7步之间,实际上还有一个后期处理过程。因为AssetLoader返回的对象有时并不能直接被游戏引擎使用。

比如各种图片文件,解析的结果是一个Image对象,但游戏需要使用的是Texture对象。jME3 设计了 AssetProcessor 接口,用于对 AssetLoader 生成的对象进行后期处理,转换成实际的对象类型。

但是AssetProcessor在整个流程中的存在感很弱,一般情况下可以当它不存在,所以我就不仔细介绍了。在以后的文章中,如果需要用到 AssetProcessor,我再详细分析。