关于cesium:CesiumJS-2022^-源码解读6-三维模型ModelExperimental新架构

26次阅读

共计 18922 个字符,预计需要花费 48 分钟才能阅读完成。

三维模型架构(即 Scene/ModelExperimental 目录下的模块)有别于旧版模型 API(即 Scene/Model.js 模块为主的一系列解决 glTF 以及解决 3DTiles 点云文件的源码),它从新设计了 CesiumJS 中的场景模型加载、解析、渲染、调度架构,更正当,更弱小。

这套新架构专门为 下一代 3DTiles(1.1 版本,以后临时作为 1.0 版本的扩大) 设计,接入了更弱小的 glTF 2.0 生态,还向外裸露了 CustomShader API

ModelExperimental 的尾缀 Experimental 单词即“实验性的”,期待这套架构欠缺,就会去掉这个尾缀词(截至发文,CesiumJS 版本为 1.95)。

接下来,我想先从这套架构的缓存机制说起。

1. ModelExperimental 的缓存机制

1.1. 缓存池 ResourceCache

缓存机制由两个主治理类 ResourceCacheResourceCacheKey 负责,缓存的可不是 Resource 类实例,而是由 ResourceLoader 这个基类派生进去的 N 多个子类:

ResourceCache 类被设计成一个相似于“动态类”的存在,很多办法都是在这个类身上应用的,而不是 new 一个 ResourceCache 实例,用实例去调用。例如:

ResourceCache.get("somecachekey...") // 应用键名获取缓存的资源
ResourceCache.loadGltfJson({/* . */}) // 依据配置对象加载 glTF 的 json

下面提到,ResourceCache 缓存的是各种 ResourceLoader,实际上为了统计这些 loader 被应用的次数,Cesium 团队还做了一个简略的装璜器模式封装,即应用 CacheEntry 这个在 ResourceCache.js 模块内的公有类:

function CacheEntry(resourceLoader) {
  this.referenceCount = 1;
  this.resourceLoader = resourceLoader;
}

你能够在 ResourceLoader.js 源码中找到一个动态成员 cacheEntries

function ResourceCache() {}
ResourceCache.cacheEntries = {};

它只是一个简略的 JavaScript 对象,key 是字符串,也就是等会要讲的 ResourceCacheKey 局部,值即 CacheEntry 的实例。

ResourceCache.load 这个静态方法中能够看到是如何缓存的:

ResourceCache.load = function (options) {
  // ...
  const cacheKey = resourceLoader.cacheKey;
  // ...

  if (defined(ResourceCache.cacheEntries[cacheKey])) {
    throw new DeveloperError(`Resource with this cacheKey is already in the cache: ${cacheKey}`
    );
  }

  ResourceCache.cacheEntries[cacheKey] = new CacheEntry(resourceLoader);
  resourceLoader.load();}

设计上,缓存的 loader 只容许 load 一次,之后在取的时候都是应用 ResourceCache.get 办法取得。

1.2. 缓存对象的键设计 ResourceCacheKey

Cesium 团队在键的设计上充分利用了待缓存资源的本身信息,或惟一信息,或 JSON 字符串自身,然而我感觉这有些不妥,较长的字符串会带来较大的内存占用。

ResourceCacheKey 也是一个相似动态类的设计,它有很多个 getXXXKey 的静态方法:

ResourceCacheKey.getSchemaCacheKey // return "external-schema:..."
ResourceCacheKey.getExternalBufferCacheKey // return "external-buffer:..."
ResourceCacheKey.getEmbeddedBufferCacheKey // return "embedded-buffer:..."
ResourceCacheKey.getGltfCacheKey // return "gltf:..."
ResourceCacheKey.getBufferViewCacheKey // return "buffer-view:..."
ResourceCacheKey.getDracoCacheKey // return "draco:..."
ResourceCacheKey.getVertexBufferCacheKey // return "vertex-buffer:..."
ResourceCacheKey.getIndexBufferCacheKey // return "index-buffer:..."
ResourceCacheKey.getImageCacheKey // return "image:..."
ResourceCacheKey.getTextureCacheKey // return "texture:...-sampler-..."

这些办法均返回一个字符串,有趣味的读者能够本人跟进源码理解它是如何从资源自身的信息“计算”出 key 的。

我认为这里存在优化的可能性,三维场景的资源会十分多,对内存容量是一个不小的要求,减小 key 的内存大小或者能晋升内存耗费体现,这须要优良的软件设计,期待官网团队优化或者大手子提交 PR。(2022 年 6 月)

2. 三维模型的加载与解析

ModelExperimental API 的次要入口就是 ModelExperimental 类,它有几个静态方法可供加载不同的模型资源(glTF/b3dm/i3dm/pnts…):

ModelExperimental.fromGltf = function (options) {/* ... */}
ModelExperimental.fromB3dm = function (options) {/* ... */}
ModelExperimental.fromPnts = function (options) {/* ... */}
ModelExperimental.fromI3dm = function (options) {/* ... */}
ModelExperimental.fromGeoJson = function (options) {/* ... */}

均返回一个 ModelExperimental 实例。从这几个办法能够看出,还是兼容了 3DTiles 1.0 中的三个次要瓦片格局的。其中,fromGeoJson 办法是一个尚未齐全实现的标准,容许应用 geojson 作为瓦片的内容,有趣味能够看 CesiumGS/3d-tiles 仓库中的一个提案。

以 glTF 为例,先看示例代码:

import {
  ModelExperimental,
  Transforms,
  Cartesian3
} from 'cesium'

const origin = Cartesian3.fromDegrees(113.5, 22.4)
const modelPrimitive = ModelExperimental.fromGltf({
  gltf: "path/to/glb_or_gltf_file",
  modelMatrix: Transforms.eastNorthUpToFixedFrame(origin)
})
viewer.scene.primitives.add(modelPrimitive)

以 glTF 模型(文件格式 glb 或 gltf)为例,从流程上来看,创立 ModelExperimental 实例的过程是这样的:

ModelExperimental.fromGltf
  new GltfLoader()
  new ModelExperimental()
    fn initialize
      GltfLoader.prototype.load
        ~promise.then → new ModelExperimentalSceneGraph

大部分的初始化工作是由 GltfLoader 去实现的,当初就进入 GltfLoader 中看看吧。

2.1. GltfLoader 的初步加载

GltfLoader 的 load 办法自身是同步的,然而它外面的过程却是一些异步的写法,应用了 ES6 的 Promise。

GltfLoader 把加载后果允诺给 Promise 成员变量 promise,能够看到在 ModelExperimental.js 模块内的 initialize 函数中,then 链接收初步加载结束的各种 glTF 组件:

// ModelExperimental.js

function initialize(model) {
  const loader = model._loader;
  const resource = model._resource;

  loader.load();

  loader.promise
    .then(function (loader) {
      const components = loader.components;
      const structuralMetadata = components.structuralMetadata;
      /* ... */
    })
    .catch(/* ... */);

  /* ... */
}

从而创立一个叫“模型场景图构造”(ModelExperimentalSceneGraph)的对象,这个对象待会会讲。

进入到 GltfLoader.prototype.load 办法中,会发现它实际上创立了一个 GltfJsonLoader,这个时候第 1 节中介绍的缓存机制就上场了:

GltfLoader.prototype.load = function () {const gltfJsonLoader = ResourceCache.loadGltfJson(/* ... */);

  /* ... */

  gltfJsonLoader.promise
    .then(/* ... */)
    .catch(/* ... */);
}

ResourceCache.loadGltfJson 这个办法中缓存器就派上用场了,首先应用 ResourceCache.get 办法获取缓存池中是否有这个 GltfJsonLoader,有则间接返回,无则 new 一个新的,并调用 ResourceCache.load 办法作缓存并加载。

相熟 glTF 格局标准的人应该都分明,glTF 格局有一个 JSON 局部作为某个 glTF 模型文件的形容信息,GltfJsonLoader 其实就是加载这部分 JSON 并简略解决成 Cesium 所需的信息的。

ResourceCache.load 实际上是间接调用传进来的某个 ResourceLoader(此处即 GltfJsonLoader)的 load 办法,并作了一次缓存。

2.2. GltfJsonLoader 申请并解析 glTF 的 JSON 局部

从 2.2 得悉,ResourceCache.load 执行的是 ResourceLoader,也即本节关怀的 GltfJsonLoader 原型链上的 load 办法,这个办法的作用就是分状况去解决传进来的参数,在异步的 Promise 链中保留解决的后果,这个后果也就是 glTF 模型的 JSON 局部。

GltfJsonLoader.prototype.load = function () {
  this._state = ResourceLoaderState.LOADING;

  let processPromise;
  if (defined(this._gltfJson)) {processPromise = processGltfJson(this, this._gltfJson);
  } else if (defined(this._typedArray)) {processPromise = processGltfTypedArray(this, this._typedArray);
  } else {processPromise = loadFromUri(this);
  }

  const that = this;

  return processPromise
    .then(function (gltf) {if (that.isDestroyed()) {return;}
      that._gltf = gltf;
      that._state = ResourceLoaderState.READY;
      that._promise.resolve(that);
    })
    .catch(/* ... */);
}

函数体两头的三个逻辑分支,别离解决了传参为 glTF JavaScript 对象、glb 或 glTF JSON 文件二进制数组、网络地址三种类型的 glTF。

对应的 5 种状况如下:

ModelExperimental.fromGltf({
  url: {// glTF JSON 本体},
  url: new Uint8Array(glTFJsonArrayBuffer),
  url: new Uint8Array(glbArrayBuffer),
  url: "path/to/model.glb",
  url: "path/to/model.gltf"
})

5 种状况均可,然而个别比拟惯例的还是给网络门路,5 种状况由几个 GltfJsonLoader.js 模块内的函数来解决。

  • 第 1 种状况,由 processGltfJson 解决;
  • 第 2、3 种状况,由 processGltfTypedArray 解决;
  • 第 4、5 种状况,由 loadFromUri 解决。

这三个函数均为异步函数,返回一个 Promise。而 glTF 的处理过程,Cesium 团队还官网设计了一个 GltfPipeline 目录,位于 Source/Scene 目录下。

loadFromUri 会继续执行 processGltfTypedArray,进而继续执行 processGltfJsonprocessGltfJson 函数就会调用 GltfPipeline 目录下的各种处理函数来应酬灵便多变的 glTF 数据。

我简略地看了一下,大略就是降级 glTF 1.0 版本的数据、补全 glTF 标准中的默认值、加载内嵌为 base64 编码的缓冲数据等操作,返回一个对立的 glTF JSON 对象。

到 processPromise 的 then 为止,glTF 的初步解析就算实现了,相当于买来的食材摘掉了发黄的菜叶,烧洗了五花肉的猪毛,清洁了瓜果的表皮,切掉了不须要的枝干,自身对食材(glTF)还没有开始做任何解决。

2.3. 状态判断

依据 glTF 设计的灵活性,glTF 数据可能存在二级加载的过程,也就是要先获取 glTF JSON,而后才获取这个 JSON 上定义的 Buffer、Image 等信息,所以异步是不可避免的。(甚至有三级加载过程,也就是二级加载到 Buffer、Image 后,仍需异步解码 Draco 缓冲或者压缩纹理数据等)

然而 CesiumJS 又是一个渲染循环程序,所以应用枚举状态值来辅助判断以后帧下,各种 loader 的状态是怎么样的,资源解决到哪一步,就分支去调用哪一步的处理函数。

譬如,GltfLoader.prototype.load 办法中,会对 GltfJsonLoaderpromise 成员字段(ES6 Promise 类型)进行 then 链式操作,批改 GltfLoader 的状态:

GltfLoader.prototype.load = function () {
  /* ... */
  const that = this;
  this._promise = gltfJsonLoader.promise
    .then(function () {
      /* ... */
      that._state = GltfLoaderState.LOADED;
      that._textureState = GltfLoaderState.LOADED;
      /* ... */
    })
    .catch(/* ... */)
  /* ... */
}

下面这段简化后的代码,意思就是在 gltfJsonLoader.promisethen 中,glTF JSON 局部曾经加载结束,那么此时就能够标记 GltfLoader_state_textureState 为“已加载(但未解决)”,也就是 GltfLoaderState.LOADED

你还能够在 Scene/ResourceLoaderState.js 模块中找到 ResourceLoaderState 这个实用于全副 ResourceLoader 的状态枚举。

2.4. glTF 的提早解决机制 – 应用 ES6 Promise

依据 2.3 大节的内容,glTF 有多级加载、解决的过程,Cesium 团队在 ES6 公布之前,用的是 when.js 库提供的 Promise,当初 1.9x 版本早已换成了原生的 Promise API,也就是用 Promise 来解决这些异步过程。

留神到 ModelExperimental.js 模块内的 initialize 函数有一段 Promise then 链,是 GltfLoader(仍以 ModelExperimental.fromGltf 为例)的一个 promise,then 链内接管 GltfLoader 上的各种组件,从而创立出 ModelExperimentalSceneGraph

function initialize(model) {
  const loader = model._loader;
  /* ... */
  
  loader.load();
  const loaderPromise = loader.promise.then(function (loader) {
    const components = loader.components;
    /* ... */
    model._sceneGraph = new ModelExperimentalSceneGraph(/* ... */);
    /* ... */
  });
  /* ... */
}

那么,loader.promise 是何方神圣呢?

代码定位到 GltfLoader.prototype.load 办法,它返回的是一个 Promise:

GltfLoader.prototype.load = function () {
  /* ... */
  const that = this;
  let textureProcessPromise;
  const processPromise = new Promise(function (resolve, reject) {
    textureProcessPromise = new Promise(function (
      resolveTextures,
      rejectTextures
    ) {that._process = function (loader, frameState) {/* ... */};
      that._processTextures = function (loader, frameState) {/* ... */};
    };
  }); // endof processPromise
  
  this._promise = gltfJsonLoader.promise
    .then(function () {if (that.isDestroyed()) {return;}
      that._state = GltfLoaderState.LOADED;
      that._textureState = GltfLoaderState.LOADED;

      return processPromise;
    })
    .catch(/* ... */)
  
  /* ... */
  return this._promise;
}

this._promisegltfJsonLoader.promisethen 链返回的 processPromise,是一个位于代码稍上方的另一个 Promise。我感觉,为了逻辑上不那么凌乱,暂到这一步为止,能够先下一个论断:

ModelExperimental 创立模型场景图的 then 链所在的 Promise 对象,追根溯源,其实是 GltfLoader.prototype.load 办法外部 new 的一个名为 processPromise 的 Promise 对象。便于探讨,无妨设创立场景图这个 Promise 为“A”。

咱们在 ModelExperimental.js 模块内的 initialize 函数内,对“A”这个 Promise 进行 then 链式操作,then 链内收到的 loader 在本大节的背景下,就是 GltfLoader,所以能力获取到 GltfLoader 上的 components,这意味着“A”必定能找到一个 resolve() 语句,把 GltfLoader 给 resolve 进来。

果不其然,在 GltfLoader.prototype.load 办法内,processPromise 内,new 了一个 textureProcessPromise,在这个 textureProcessPromise 内,就找到了 processPromise 的 resolve 语句:

GltfLoader.prototype.load = function () {
  /* ... */
  const that = this;
  let textureProcessPromise;
  const processPromise = new Promise(function (resolve, reject) {
    textureProcessPromise = new Promise(function (
      resolveTextures,
      rejectTextures
    ) {that._process = function (loader, frameState) {
        /* ... */
        if (loader._state === GltfLoaderState.PROCESSED) {
          /* ... */
          resolve(loader); // 在这儿
        }
      };
      that._processTextures = function (loader, frameState) {/* ... */};
    };
  });
  
  /* ... */
}

能够看到,它是在 that._process 这个办法上的 GltfLoaderState.PROCESSED(处理完毕)状态分支上 resolve 的。

that 就是 GltfLoader 自身,_process 是一个初始化 GltfLoader 时定义的空函数,直到在此时才会齐全定义。这个办法,实际上是下一大节(2.5)的内容,也即解决由 GltfJsonLoader 初步解决的 glTF JSON,产出用于创立 ModelExperimentalSceneGraph 的组件。

为什么要层层封装呢?这对于浏览源码的人来说心智累赘略大。

起因就是 glTF 标准的定义,很灵便,有多级加载的可能性。Cesium 团队在逻辑上是这样程序组织 Promise 代码的:

  • 先由 GltfJsonLoader 加载、解析 glTF 的 JSON 局部,降级 glTF 版本、补全默认值、解析内嵌缓冲数据后,向下传递这个初步解析的 glTF JSON 对象;
  • 向下传递是借助 GltfJsonLoader 的一个 promise 字段成员,接收者位于 GltfLoader.prototype.load 办法中,then 链首先会标记 GltfLoader 的两个状态为“已加载”,而后返回 load 办法中创立的一个用于下一步加工操作的 processPromise,这个 Promise 最终 resolve 的值即 GltfLoader 自身;而这个 processPromise,是解决 glTF 的 JSON 的,要把 JSON 转换为组件 – 也就是创立 ModelExperimentalSceneGraph 的原材料,基于 glTF 数据的特色,这一步解决又要分两步:先解决纹理,再解决其余的数据;
  • 由此,processPromise 内只有一个操作,那就是 new 一个 textureProcessPromise,确保材质纹理的解决优先级最高;
  • textureProcessPromise 中补全了 GltfLoader 的两个解决办法的定义,即 _process_processTextures,前者将会 resolve processPromise,后者将会 resolve textuerProcessPromise

请留神,此时还未正式执行处理函数,也就是 GltfLoader_process_processTextures 办法。

在同步操作上来说,最外层的 ModelExperimental 曾经由模块内的函数 initialize 走完了,也就是 ModelExperimental 曾经创立进去了,然而因为 glTF 只初步加载了 JSON 局部,所有的资源都还没筹备齐全,还没加工成组件,也就创立不了 ModelExperimentalSceneGraph,没有这个模型场景图构造对象,也就创立不出 DrawCommand。

然而,到当初为止,GltfJsonLoader 的使命曾经实现,下一步将由 GltfLoader 应用解决好的 glTF JSON 创立模型组件,即 2.5 大节的内容。

这里讲的有点超前,然而这些都是下文的内容,请急躁往下看,我抵赖这部分应用 Promise 的确有点麻烦。

一旦 GltfLoader_process 流程走到了 GltfLoaderState.PROCESSED,也就是 glTF JSON 全副处理完毕,就意味着组件已创立结束,能够创立 ModelExperimentalSceneGraph 了;而纹理一边则由 _processTextures 办法来实现。

所以说为什么 glTF 应用了提早解决机制呢?是因为根自 ModelExperimental.js 模块内的 initialize 函数曾经执行结束,只实现了第一步:实例化了一个 ModelExperimental,就算随着工夫推向前,最多能达到的状态也只是 GltfJsonLoader 初步加载解析结束 glTF JSON,不会发动下一步。

那么下一步的 GltfLoader_process_processTextures 由谁执行呢?这里先漏一点,是由 scene.primitives.update(),也就是场景的更新过程触发的 ModelExperimental.prototype.update 过程来执行的,见本文第 3 节。

2.5. 模型组件创立

祝贺你,2.4 大节算是一个头脑风暴,如果你胜利地看下来了。

这一步在 2.4 大节尾曾经走漏了,实际上就是 GltfLoader 接过了 GltfJsonLoader 的大旗,进一步随场景的更新过程执行 _process_processTextures 的过程。

这个过程,将创立出模型组件,也就是 ModelExperimentalSceneGraph 的原材料。

还记得我在后面是怎么形容 GltfJsonLoader 的行为的吗?

本文 2.2 大节

相当于买来的食材摘掉了发黄的菜叶,烧洗了五花肉的猪毛,清洁了瓜果的表皮,切掉了不须要的枝干,自身对食材(glTF)还没有开始做任何解决。

这一步由 GltfLoader 加工进去的模型组件,相当于是把初步解决的食材进行了切割、吸干血水,乃至焯水等正式炒菜前的“前置步骤”。

模型组件,由 Scene/ModelComponents.js 模块定义,组件有如下数种:

Quantization
Attribute
Indices
FeatureIdAttribute
FeatureIdTexture
FeatureIdImplicitRange
MorphTarget
Primitive
Instances
Skin
Node
Scene
AnimatedPropertyType
AnimationSampler
AnimationTarget
AnimationChannel
Animation
Asset
Components
TextureReader
MetallicRoughness
SpecularGlossiness
Material

实际上很靠近 glTF JSON 的各个对象,毕竟只是简略的切割、去血水、焯水。这一步没什么太非凡的操作,因为之前的文章讲过 Scene 是如何更新 Primitive 的,就省去这个前置流程,间接看到 ModelExperimental.prototype.update 办法:

ModelExperimental.prototype.update = function (frameState) {processLoader(this, frameState);
  
  /* ... */
}

function processLoader(model, frameState) {if (!model._resourcesLoaded || !model._texturesLoaded) {model._loader.process(frameState);
  }
}

上来第一步就是调用 ResourceLoader 原型上的 process 函数,这里仍以 ModelExperimental.fromGltf 为例,那么应该执行的就是 GltfLoader.prototype.process

GltfLoader.prototype.process = function (frameState) {
  /* ... */

  this._process(this, frameState);
  this._processTextures(this, frameState);
};

因为 2.4 大节曾经介绍了这两个解决办法的具体定义地位,咱们间接转到 GltfLoader.prototype.load 办法,找到他们的定义。不难发现,他们外部还是做了状态判断,进行不同状态的逻辑分叉:

that._process = function (loader, frameState) {if (!FeatureDetection.supportsWebP.initialized) {/**/}
  if (loader._state === GltfLoaderState.LOADED) {/**/}
  if (loader._state === GltfLoaderState.PROCESSING) {/**/}
  if (loader._state === GltfLoaderState.PROCESSED)  {/**/}
}

that._processTextures = function (loader, frameState) {if (loader._textureState === GltfLoaderState.LOADED) {/**/}
  if (loader._textureState === GltfLoaderState.PROCESSING) {/**/}
  if (loader._textureState === GltfLoaderState.PROCESSED) {/**/}
}

GltfLoader 不同的状态走不同的路线。

生成模型组件的分叉位于 if (loader._state === GltfLoaderState.LOADED) 分支下的 parse 函数调用内。这个 parse 函数,定义在 GltfLoader.js 模块内。

简略过一下这个函数,大部分的解析逻辑扩散在 loadNodesloadSkinsloadAnimationsloadScene 这几个模块内的函数中,其中 loadNodes 是从 glTF JSON 中的 nodes 成员开始的,通过 meshesprimitives,而后是 loadMaterialloadVertexAttribute 等齐上阵,把 glTF JSON 中对于几何图形的信息全副拆解进去,生成 ModelComponents 命名空间下的各种组件对象。

parse 函数的下半局部,有对额定数据的异步解决 promise 进行并发执行的语句:

Promise.all(readyPromises)
  .then(function () {if (loader.isDestroyed()) {return;}
    loader._state = GltfLoaderState.PROCESSED;
  })
  .catch(rejectPromise);

Promise.all(loader._texturesPromises)
  .then(function () {if (loader.isDestroyed()) {return;}
    loader._textureState = GltfLoaderState.PROCESSED;
  })
  .catch(rejectTexturesPromise);

是这两个并发操作决定了 GltfLoader 的状态为“处理完毕”的。

而一旦被设为 GltfLoaderState.PROCESSED,那么在 _process_processTextures 这两个函数中,就会执行 2.4 大节中提及的两个 Promise —— processPromiseprocessTexturesPromise 给 resolve 掉,进行下一步创立场景图构造,也就是 2.6 大节。

2.6. 模型场景图构造的创立

有了 GltfLoader.prototype.process 办法解决进去的各种组件后,就能够进一步创立模型场景图构造对象了,也就是 ModelExperimentalSceneGraph 实例。

相干代码位于 ModelExperimental.js 模块内的 initialize 函数中:

function initialize(model) {
  const loader = model._loader;
  /* ... */
  const loaderPromise = loader.promise.then(function (loader) {
    const components = loader.components;
    /* ... */

    model._sceneGraph = new ModelExperimentalSceneGraph({
      model: model,
      modelComponents: components,
    });
    model._resourcesLoaded = true;
  });
  /* ... */
}

ModelExperimentalSceneGraph 的创立其实比较简单,GltfLoader 曾经把最沉重的解决和解析工作实现了,剩下的工作,就是把食材放进锅里炒熟,出菜即 ModelExperimentalSceneGraph,它的初始化函数只是把模型组件做了一些简略的解决。

glTF 模型有动态的模型,也有带骨骼、蒙皮动画的模型,恰好是这些动静的模型还须要再一次依据“运行时”来取得以后帧的动态数值,能力交给 WebGL 绘图,负责这部分工作的,就是这个 ModelExperimentalSceneGraph。这部分在第 3 节中会简略提及,此处省略 ModelExperimental 的更新过程。

2.7. 本节小结

多种格局拼装成组件,这是一次加工的后果。而后组件创立场景图构造,这是二级加工的后果。本节以 glTF 模型为例,穿过层层 Promise 穿插调用,理清了 Cesium 为了兼容性做出的新逻辑。简略的说,能够依次为如下流程(以 glTF 为例):

  • ModelExperimental.fromGltf 入场,创立 ModelExperimental,执行初始化,创立 GltfLoaderGltfJsonLoader
  • GltfJsonLoader 后行,初步加载并清洁了 glTF JSON
  • GltfLoader 接过初步解决的 glTF JSON,创立模型组件
  • 在 Promise 链的起点,创立出 ModelExperimentalSceneGraph

别忘了状态机制和缓存机制的功绩!

3. 模型的更新与 DrawCommand 创立

CesiumJS 没有选用 ES6 的类继承,也没有用原型链继承,ModelExperimental 是一种“似 Primitive(PrimitiveLike)”,它与原生 Primitive 相似,有 update 办法来创立 DrawCommand

Scene 的更新过程不赘述,能够参考系列的第 2 篇。此处间接跳转至更新办法:

ModelExperimental.prototype.update = function (frameState) {
  /* 长长的更新状态过程 */
  buildDrawCommands(this, frameState);
  /* ... */
  submitDrawCommands(this, frameState);
}

这个过程非常复杂,但 Cesium 团队封装地还挺清晰的,这套模型新架构既要兼容非 glTF 格局的 pnts,还要思考到工夫相干的 glTF 骨骼、蒙皮内置动画。

别忘了,这个 update 办法第一步是调用 GltfLoader.prototype.process 办法,见 2.5 大节。

简略的说,在更新的过程中,绘图指令(DrawCommand)由 ModelExperimentalSceneGraph.prototype.buildDrawCommands 办法创立,这个办法会遍历场景图构造对象下所有 ModelComponents.Primitive 的状态,最终是由 buildDrawCommands.js 模块应用这些状态创立出 DrawCommand 的。

着色器也有专门的新设计,有趣味的能够去看 Source/Shaders/ModelExperimental 上面为这套模型新架构设计的着色器,你会发现好多“XXXStage.glsl”代码,这是一种可扩大的设计,即为残缺的模型着色器增加两头阶段,实现多种成果。待会在 3.1 大节中还会介绍这些阶段哪来的。

具体一点来说,ModelExperimental.prototype.update 还包含了如下我的项目:

  • 自定义着色器更新
  • 更新光照贴图
  • 点云相干更新
  • 有因素表的模型则更新因素表
  • 有裁剪面的则更新裁剪面信息
  • 创立绘图指令(DrawCommand)
  • 更新模型矩阵
  • 更新场景图构造对象
  • 提交绘图指令到 frameState,结束战斗

具体的过程便不再深刻探讨,还是倡议 有 glTF 标准根底 去浏览这部分源码,会更容易一些。

3.1. 创立 DrawCommand 及一些乏味的设计

既然创立 DrawCommand 是场景图构造对象最重要的使命,那么就看看这个过程有什么乏味的货色:

ModelExperimental.prototype.update = function (frameState) {
  /* 长长的更新状态过程 */
  buildDrawCommands(this, frameState);
  /* ... */
  submitDrawCommands(this, frameState);
}

function buildDrawCommands(model, frameState) {if (!model._drawCommandsBuilt) {model.destroyResources();
    model._sceneGraph.buildDrawCommands(frameState);
    model._drawCommandsBuilt = true;
  }
}

function submitDrawCommands(model, frameState) {
  /* ... */
  if (showModel) {
    /* ... */
    const drawCommands = model._sceneGraph.getDrawCommands(frameState);
    frameState.commandList.push.apply(frameState.commandList, drawCommands);
  }
}

也就这些,次要的工作还是在场景图对象上的。

实际上,动态资源能够不思考那么多“场景图对象中的设计”,这些额定的设计次要还是为动静模型思考的。

一个是“模型组件运行时再次封装对象”,另一个是“分阶段”。

前者有好几个类,和几何图形相干的是 ModelExperimentalNodeModelExperimentalPrimitiveModelExperimentalSkin 等,很显然,这些就是对应模型组件的动态化封装,例如 ModelExperimentalNode 对应的是 ModelComponents.Node。它们与模型组件对象是共存的:

function ModelExperimentalSceneGraph(options) {
  /* ... */
  
  this._components = components;
  this._runtimeNodes = [];
  this._runtimeSkins = [];
  
  /* ... */
}

这些运行时对象由模块内的初始化函数 initialize 递归遍历创立而来。

“阶段”是什么?你在 Scene/ModelExperimental 目录下能够找到挺多“Stage”类的,它们的作用相当于给“模型组件运行时再次封装对象”减少一些可选的性能,每个“阶段”对象都会随着 buildDrawCommands 函数触发一次解决办法,这样就能影响最终创立进去的 DrawCommand

不仅“模型组件运行时再次封装对象”容许有“阶段”,场景图构造对象本人也有:

function ModelExperimentalSceneGraph(options) {
  /* ... */
  
  this._pipelineStages = [];
  this._updateStages = [];
  this.modelPipelineStages = [];
  
  /* ... */
}

在调用 ModelExperimentalSceneGraph.prototype.buildDrawCommands 办法创立 DrawCommand 时,这些“运行时对象”会调用本人原型上的 configurePipeline 办法(如果有),决定以后帧要选用那些阶段影响生成的绘制指令。

ModelExperimentalPrimitive 为例,它的可配置的阶段就很多了:

ModelExperimentalPrimitive.prototype.configurePipeline = function (frameState) {
  const pipelineStages = this.pipelineStages;
  pipelineStages.length = 0;
  /* ... */
  
  // Start of pipeline --------------------------------
  if (use2D) {pipelineStages.push(SceneMode2DPipelineStage);
  }
  
  pipelineStages.push(GeometryPipelineStage);
  
  /* 很长,很长... */
  
  pipelineStages.push(AlphaPipelineStage);
  pipelineStages.push(PrimitiveStatisticsPipelineStage);

  return;
};

阶段的具体执行,就请读者自行浏览 ModelExperimental.js 模块内的 buildDrawCommands 函数了。

这就是场景图对象的一些辅助设计,不难,目标只是更好地升高耦合,加强这套 API 的可扩展性。

3.2. 可能有帮忙的切入点

这里就长话短说了,有了上述提纲挈领的骨干代码,我想这些更适宜有特定优化或学习需要的读者,自行钻研:

  • 剔除优化:能够通过设定 ModelExperimental 的色彩透明度为全透明,或者间接设置 show 属性为 false,就能够粗犷地不创立 DrawCommand 了;DrawCommand 自身是会被 View 筛选的,参考本系列文章的第 2 篇;
  • 渲染程序调度:即最终传递给 DrawCommand 的 Pass 枚举值,这些受 3.1 大节中各种阶段处理器的影响,见 ModelExperimentalSceneGraph.prototype.buildDrawCommands 办法内的 ModelRenderResourcesNodeRenderResourcesPrimitiveRenderResources 的传递处理过程;
  • 因素表、属性元数据与款式:因素表由属性元数据创立而来,参考 ModelExperimental.js 模块内的 createModelFeatureTables 函数(初始化时会判断是否须要调用),会依据属性元数据创立 ModelFeatureTable,属性元数据参考 3DTiles 标准中的属性元数据(Metadata)局部;ModelExperimental 对象是能够利用 Cesium3DTileStyle 的,然而款式不能与 CustomShader 共存;
  • 模型渲染统计信息:定义在 ModelExperimental 对象的 statistics 成员上,类型是 ModelExperimentalStatistics
  • 自定义着色器:随 ModelExperimental 的更新而更新,次要是更新纹理资源。

属性元数据、自定义着色器、光影渲染、裁剪立体、GPUPicking 这些能够成为专题的内容,当前思考再写,本篇次要介绍的是三维模型架构的主线脉络。

4. 本文总结

模型架构的变革,使得源码在解决 3D 格局上具备更弱小的可扩展性、可维护性。CesiumJS 抉择了 glTF 生态,为兼容 glTF 1.0、2.0 做出了许多封装。当然,还保留了 3DTiles 1.0 原生的几种瓦片格局的解析,并正在设计 GeoJSON 为瓦片的解析路径。

整个模型架构,单从各种类的封装角度看,为了对立能在更新时生成 DrawCommand,势必有一个对立的内置数据类封装,也就是 ModelExperimentalSceneGraph,模型场景图,它由某个 ResourceLoader 异步加载实现的 组件 创立而来,这些组件是各种格局(glTF 1.0/2.0、3DTiles 1.0 瓦片格局、GeoJSON)应用不同的 loader 解析而来,大抵分层如下:

缓存池 + ResourceLoader + 运行时场景图多阶段 的设计使得将来扩大其它格局有了可扩大、高性能的可能,然而其中的一些仓促未优化的局部仍待解决。

兴许有人会拿 ThreeJS 来比拟,然而我认为要在同等条件下能力比拟。ThreeJS 也能加载 glTF,然而它的主库并没有加载、解析的性能,这部分性能拆到 Loader 里去了,满血版的 ThreeJS 预计代码量也挺可观的。CesiumJS 的侧重点与 ThreeJS 不一样,鱼是陈腐,熊掌是硬核,看你怎么选,二者得兼,必然付出微小的代价。

如果说 ModelExperimental 这套架构是 Primitive API 的特定数据格式具象化下层封装,那么下一篇 3DTiles 将是 Cesium 团队对 Web3DGIS 的最大奉献,也是 CesiumJS 中三维数据技术的集大成者。

正文完
 0