关键字:Cesium glTF webGL 技术
大纲:
1 glTF 简介,这是一个什么东西,有哪些特点
2 Cesium 如何加载,渲染 glTF,逻辑结构和关键技术
3 个人总结,从 glTF 学习如何设计一个二进制格式,个人想法分享
共计 4000 字 | 建议阅读时间 未知
glTF 简介
之前介绍了 Cesium 的 Property,Material,Batch,GroundPrimitive 这些内容,可以说是简单地物和风格的解决思路。当 Cesium 把这些技术点整合起来,我们便具备了渲染模型的威力。也就是今天要讲的 glTF 模型渲染。
glTF 的全称是 GL 传输格式,是一种针对 GL(WebGL,OpenGL ES 以及 OpenGL) 接口的运行时资产 (asset)。在 3D 内容的传输和加载中,glTF 通过提供一种高效,易扩展,可协作的格式,填补了 3D 建模工具和现代 GL 应用之间的空白。github 上有对该数据规范的详细介绍,春节期间我翻译了其中的核心部分,有兴趣的可以。
上图是 glTF 的一个大概结构,分为四大块,最上面的 json 是一个表述,描述该模型的节点层级,材质,相机,动画等相关逻辑结构,bin 则对应这些对象的具体数据信息,glsl 是对该模型渲染的着色器,针对该模型的数据信息,给出渲染 "配方",当然还有纹理内容。大块内容可以以 Base64 的编码内迁到文件中,方便拷贝和加载,也可以以 URI 的外链方式,侧重重用性。
下图是 json 中包含的描述信息,内容详细,比如 mesh,纹理,蒙皮和动画,定义了 accessor 的访问器规则,同时还给出了相机,节点这些场景管理的信息。充分体现了 glTF 规范设计的强大,让我想到了一句话:" 解决问题固然重要,但通过设计避免问题则更胜一筹 "。可以说,该规范是对复杂三维模型的一个很不错的抽象,考虑的很充分,之间的接口定义也很规范,如果你也想设计一个二进制数据规范的话,这是一个很好的学习,借鉴范本。
glTF 渲染
东西再好,光说不练假把式。设计好了,只是一个开始而不是完结,还需要持续的推广和应用。这年头酒香也怕巷子深,伯牙难觅钟子期的画面有没有。下面我们来看看 glTF 是如何渲染模型的,talk is cheap and show me the code~
▽加载 & 渲染
- var entity = viewer.entities.add({
- name : url,
- position : position,
- orientation : orientation,
- model : {
- uri : url,
- minimumPixelSize : 128,
- maximumScale : 20000
- }
- });
- var model = scene.primitives.add(Cesium.Model.fromGltf({
- url : './duck/duck.gltf'
- }));
- // 内部通过该方法来解析JSON对象,获取表述信息和具体的数据内容
- function parseBinaryGltfHeader(uint8Array) {
- var json = getStringFromTypedArray(uint8Array, sceneOffset, sceneLength);
- return {
- glTF: JSON.parse(json),
- binaryOffset: binOffset
- };
- }
如上是加载 glTF 的过程,也是提供两种方式,一种是以 Entity 的方式,一种是以 Primitive 的方式,消费数码相机(前者)和单反相机(后者)的差别。同时,Cesium 对 Model 的渲染也是基于状态的更新的,这个和地球,Entity 的渲染思路是一致的。Model 有三个状态,加载 (NEEDS_LOAD),解析 (LOADING),和结束 (LOADED)。在不同状态下做该做的事,各司其职,互不干涉。
- Model.prototype.update = function(frameState) {
- // Key 1 解析json对象中的各个对象
- // 比如是否有动画,数据视图具体情况,是否有扩展属性等
- if ((this._state === ModelState.NEEDS_LOAD) && defined(this.gltf)) {
- parse(this);
- this._state = ModelState.LOADING;
- }
- // Key 2 解析后,对需要调用的内容赋值
- // 比如顶点数据和索引,材质,纹理等封装,动画,Runtime封装到对应的RuntimeNode
- if (this._state === ModelState.LOADING) {
- createResources(this, frameState);
- this._state = ModelState.LOADED;
- }
- // Key 3 更新动态属性,传递到对应的着色器参数中
- // 比如动画,骨骼等,更新对应变量的节点矩阵,重新梳理节点层级对应的矩阵等参数
- if ((show && this._state === ModelState.LOADED) || justLoaded) {
- updateNodeHierarchyModelMatrix(this, modelTransformChanged, justLoaded, frameState.mapProjection);
- }
- // 渲染队列
- if (show && !this._ignoreCommands) {
- var commandList = frameState.commandList;
- // ……
- }
- }
如上是 Model 的状态更新函数,每一个状态只专注于自己的业务,当处理完后完成状态的更新。update 实现实时更新和渲染。这里以读一本书为例来描述这个过程,首先,我们先解析 glTF 的头信息,也就是 json 对象,了解该模型的大概结构,这就好比一本书的目录,当我们对一本书感兴趣的时候,都会先看看目录,了解一个大概;接着,我们开始解析 glTF 数据,将每一个结构中的数据解析赋值,这是最复杂,也是最关键的过程之一,我们开始逐章节的阅读这本书;最后,我们彻底解析完该数据,则构造对应的 DrawCommand,添加到渲染队列中;如果该数据中包含一些时态数据,比如动画,蒙皮等,则每一帧都要动态的调整。这就是 update 中主要的四个状态和逻辑,完成该模型的渲染。下面我们详细介绍这个过程中三个重要的部分。
▽BufferView&Accessor
如图,红框部分,从下往上看。Buffer 缓存是一个二进制的数据块,是几何对象,动画和蒙皮等数据信息的组合,在 json 中申明了这个数据块的类型 arraybuffer 和长度。BufferView,缓存视图,是 Buffer 的子集,如果 Buffer 是一本书的内容,那么 BufferView 就是一个目录,将这本书划分成章节,并表示该章节的起始页和长度。缓存和缓存视图并不包含类型信息。他们只是简单定义从文件中取出的原始数据,并不知道这些数据到底有什么涵义和结构。glTF 文件中的对象(网格,蒙皮,动画)都不会直接访问缓存或缓存视图,而是通过 Accessor 访问器,这样我们拿到这块数据后,知道这块数据是 vec4,float 还是其他类型。
▽Mesh
如上,有了访问的规范,我们还得知道一个几何对象的逻辑结构,就好比拼图游戏,我们能拿到一块块拼图,心中还要有一个轮廓,能把这些拼图拼成一个完整的图像。下面我们来看看 Mesh 这张图是有哪些部分构成的,一切的一切还是从上方图的红框开始。
该 Mesh 可以有多个 Primitive 组成,每个图元有 attribute 顶点数据,indices 顶点索引,mode 类型为 triangles,还有 material 材质,这些内容我们已经在之前的章节介绍过,不知道你还给我多少。我们再看 material 对象,里面用到了 technique,其他的都是具体的光照模型的参数值,稍微特殊的是 diffuse,是一张纹理。technique 里面封装了着色器需要的参数,包括 attribute 和 uniform,以及 GL 状态 states,对应的着色器代码 program,还有 shaders,texture 纹理的封装等,这些对象的值是一个 accessor,进而获取对应的值。。这些对象我们之前都详细介绍过,我们顺藤摸瓜,算是对之前内容的温习,并串联成一个完整体系。可以说,里面的技术点都和以前的内容一样,glTF 定义了他们之间交互的规范,将他们封装为一个整体。
▽Scene&Animation
在很多应用中,只是从一个建模数据包中带出单一对象,这并不充分。因此 glTF 还包含整个场景的关系,包括节点,变换矩阵,变换的层级关系,网格,材质,相机和动画,试图保存所有信息。这是一个场景树的逻辑,算是 glTF 的一个优化。如上图,该 Scene 中有三个 node,其实 Cesium_Air 节点对应的 mesh 名字为 Geometry-mesh090,他还有两个子节点。
当然,Cesium 内部提供了动画的解析(_runtime),在 createRuntimeAnimations 方法中实现,详细的自己来看。其中包括 TIME 计时器,samplers 插值方式,所对应的动画节点和具体的属性(比如 rotation)。这样每一帧会更新对应的值。
总结
如上是 glTF 的一个介绍,下面来谈几点个人的想法。
▽必要性
设计一个二进制文件的风险很大的,多数情况下会是一个失败的产品。所以,当你觉得你需要一个新的数据格式时,你最好的选择就是回家睡觉,早上起来想想是否还有这种冲动。如果时间久了,冲动还在,再理性的衡量也不迟。《Unix 编程艺术》里面概括了两个衡量点:时效性和数据量。当已有的数据格式无法满足你对这两点的需要时,或许你真的有充分的理由来设计一个新的数据格式了。
设计一个新的数据格式是一件很有挑战的事情,而且由于自身的局限性,剧情很可能是这样的,你设计完了,很好的解决了你的问题,你觉得很棒,但不久的未来,随着应用的推广,需求的增加,现有的数据格式无法满足业务的多样性,有可能是你考虑不充分,有可能是过分需求,这些都是未知的风险,让你陷入两难,增加版本号,数据规范升级,版本多了会很混乱,也会出现旧版本读取新数据这种无法解决的隐患。微调的话,则会弄脏现有的数据规范。慢慢的,时间证明了它是多么的失败。
所以,这个人经验一定要丰富,谨慎,能够做决定的人越少越好,不仅着眼于当前要解决的问题,还要综合考虑通用性。但这又是一个困惑,也要控制它的应用范围,随着硬件性能的提高,避免过渡设计和优化。比如 glTF 提供了扩展,提供了场景树,相机的信息,这都是出于通用性的考虑,但这个是否实用,就不好判断了。
▽Accessor&Json 表述
这是 glTF 数据读取的机制,设计的很优雅,很值得我们学习。
通常,对一个二进制文件,我们都是按照规范格式逐个字节的解析,这样就有一个很大的风险,一步错了,后面的都会错。因为这太容易出错了,万一版本升级,多加了几个字节,就会导致整个文件无法解析,我们增加了超级纠错的机制。对每一块数据前面加一个长度,或者校验码,检测这块数据是否完整,每块读完后,根据这块的长度跳到下一个二进制块重新开始(而不是循序渐进),这样每一块坏了不会影响下一块。问题解决了,但并不实用,还仅仅是从技术层面的处理,所以我们需要增加规范,从设计上来解决这个问题,增加一个表述信息,根据 accessor 的规范来读取二进制流。(个人猜测 protocol buffer 也是这样的设计思路)
当然,如果实现这种读取方式,我们就需要一个 "目录" 页,glTF 提供了 json 形式的 header,这个 header 是 json 形式,也可以是 xml 格式,好处是灵活,兼容性强。相比 xml,json 是浏览器内部封装成对象,效率高,缺点查询不方便。
▽产品化
万事开头难,何况是创造一个新的东西,而且,当这个新的东西落地后,一切才刚刚开始。你需要完善的文档和配套工具,需要和相关的厂商(数据 & 硬件)合作,是否开源,许可协议等,一堆事情要做,而且要做好。如果只是草草了事,只是看起来漂亮,还是无法得到别人的认可,纯属自娱自乐的行为,那就大为失色了。
不妨低调的提供一个不是规范的规范,有了足够的项目实践和完善,踏踏实实的用起来,得到了用户的认可,文档工作也做细致了,标准化规范化自然也就提上了议程。好的技术,好的产品,好的标准,一切都是环环相扣的,所以,做好每一步,顺其自然,就像那首歌一样,Que sera sera, Whatever will be will be。
不知道有多少看到这,不容易啊,推荐有缘人看看这部推荐的电影,一部关于自闭症的动画片,我很喜欢~
来源: