关于cesium:CesiumJS-技术博客glTF-模型Model加载新架构

44次阅读

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

原文:https://cesium.com/blog/2022/…

CesiumJS 和 glTF 之间有一段很长的单干关系。在 2012 年,CesiumJS 就实现了一个 glTF 加载器,是最早的一批加载器了,过后的 glTF 叫做“WebGLTF”。

这十年间,产生了很多事件。glTF 1.0 标准公布、glTF 1.0 的嵌入式着色器演进成 glTF 2.0 的 PBR 材质,glTF 的社区扩大也在蓬勃成长。最近公布的 下一代 3DTiles 间接应用了 glTF,并容许在顶点粒度下来编码属性元数据。

这么多年教训积攒下来,官网团队曾经知悉了社区在实践中是如何应用 glTF 和 3DTiles 的,当初是时候把积攒变现布局将来了。

为了实现一个更强的加载器,须要有一套残缺的设计,设计指标如下:

  • 将 glTF 的 加载 和模型的 渲染 解耦
  • 反对逐顶点粒度的属性元数据
  • 反对自定义着色器,并且在着色器的拼接上要做到可扩大
  • 缓存纹理对象,当纹理在不同的模型间共享时,升高内存占用
  • 与 3DTiles 中的其它瓦片格局(例如 pnts)建设更清晰的结合点
  • 进步或至多不能低于原有的加载、渲染性能

尽管在公共 API 的调用层面来说,简直没有扭转,然而底层付出的致力能够说是釜底抽薪了。

上图为驰名的 COLLADA 鸭子模型,在 2012 年转换成 glTF 并加载的成果

1. 加载一个 glTF 模型

Model 类将模型的加载、解析职能拆散到一些“资源加载类”。

首先是 GltfLoader 类,这个类负责获取 .glb.gltf 文件,以及连带的任何内部资源,例如二进制文件、贴图图像文件等;glTF 的 JSON 局部经由一系列转换后,会生成一个 ModelComponents 对象,这个对象的构造和 glTF 本人的 JSON 局部很类似,但很多属性都由 CesiumJS 本人的对象来填充。例如,glTF 纹理对象被转换为 CesiumJS 的 Texture 实例,还有几个辅助函数和类用于解析来自下一代 3DTiles 引入的 EXT_mesh_featuresEXT_structural_metadata 扩大,以获取更丰盛的信息。

上图:GltfLoader 解析 glTF 文件,将其载入内存中,生成 ModelComponents 对象。GltfLoader 还会用到其它几个子 Loader 合成工作,例如加载纹理或上载顶点缓冲到 GPU

glTF 容许通过共享资源来升高存储空间、网络传输带宽压力、解决工夫。这个机制能够产生在很多个形象层之间。例如,两个 glTF 的 primitive 对象能够共享同一个几何缓冲区,然而用不同的材质对象。或者,两个不同的 glTF 文件援用雷同的纹理贴图文件。在运行时,同一个 glTF 在场景中可能会渲染多处;在上述这些状况中,数据资源该当只须要加载一次,而后尽可能地反复利用。

当初,CesiumJS 的新 Model 架构(类)应用一个全局的 ResourceCache 类来存储那些须要被共享的资源,例如纹理、二进制缓冲、glTF 的 JSON 局部等。当加载程序代码须要一个资源的时候,首先就会查看缓存。一旦命中缓存,那么被缓存的资源就计数 +1,并返回该资源。只有缓存中没有这份所需的资源时,才会加载它到内存中。无论某个资源是单个 glTF 外部、多个 glTF 共享或单个 glTF 的多份正本共享,它只加载一次到内存中。

这种缓存机制对加载 3DTiles 是有帮忙的,这些瓦片有可能会共享纹理贴图。之前的 glTF 加载架构没有为纹理设置全局缓存,当初具备这个机制后能够大大减少内存的占用。

上图:在 San Diego 这个 3DTiles 数据集中,应用新的 Model 架构内存从原来的 564 MB 降至 344 MB

须要留神的是,即便具备全局资源缓存机制,微小的场景仍需加载大量的内部资源,为了确保数据尽可能地高效传递,CesiumJS 应用了并行形式收回网络申请,迫近浏览器的极限。

2. 着色器优先的模型渲染设计

新的 Model 架构显示出了更强的灵活性,官网团队也心愿在渲染上同样具备灵活性。渲染一个 glTF 是一个简单的工作。 glTF 标准容许多种多样的材质、个性,例如动画等。此外,CesiumJS 减少了许多运行时的性能,例如拾取、款式化、自定义着色器。所以,官网团队心愿有一个可保护的设计来长期解决这些标准细节。

在筹备一个模型的渲染中,最简单的局部就是为其生成 GLSL 着色器代码,团队一开始就以这个为出发点,在 3D 图形开发中,有两种形式来生成简单的着色器。

第一种,就是“超级着色器”,所有的状况尽可能地写在一个大大的 GLSL 文件中,并应用不同的预处理宏来抉择代码,通知编译器在运行时抉择哪一部分来编译、执行。GLSL 代码能够别离存储在独立的 glsl 文本文件中,与 JavaScript 代码解耦。对于着色器内的算法、流程当时曾经晓得的状况,这种设计是很不错的。

例如,所有的 glTF 材质均遵循 PBR 渲染算法,依据 glTF 的材质对象来决定应用哪些纹理和惟一值(Uniform)。具体举例,设一些模型应用有纹理的材质,而另一些模型应用常量的漫反射色彩时,上面是 MaterialStageFS.glsl 着色器代码实现的简略摘要:

vec4 baseColorWithAlpha = vec4(1.0);
#ifdef HAS_BASE_COLOR_TEXTURE
baseColorWithAlpha = texture2D(u_baseColorTexture, baseColorTexCoords).rgb;
#elif HAS_BASE_COLOR_FACTOR
baseColorWithAlpha = u_baseColorFactor.rgb;
#endif

float occlusion = 1.0;
#ifdef HAS_OCCLUSION_TEXTURE
occlusion = texture2D(u_occlusionTexture, occlusionTexCoords).r;
#endif

译者注:通过宏定义来笼罩 glTF 所用 PBR 材质算法的各种分支状况,例如这个代码片段中的根底漫反射色彩(baseColorWithAlpha)以及遮挡因子(occlusion)。

第二种,就是在运行时动静生成着色器代码。当影响的因素是不确定的时候(例如某些属性可能不存在,也可能存在),这种设计的劣势就体现进去了。

例如,当一个模型在做蒙皮变换时,权重和关节矩阵的数量是 glTF 外面决定的,着色器并不会提前晓得。动静生成代码比 #ifdef 宏能提供更好的逻辑,然而这种机制会呈现 JavaScript 和 GLSL 代码的大量交织重叠,不利于代码浏览。

因而,动静着色器代码的生成都要审慎地进行,以放弃可维护性。上面代码片段是旧版的 processPbrMaterials.js 实现:

if (hasNormals) {
  techniqueAttributes.a_normal = {semantic: "NORMAL",};
  vertexShader += "attribute vec3 a_normal;\n";
  if (!isUnlit) {
    vertexShader += "varying vec3 v_normal;\n";
    if (hasSkinning) {
      vertexShaderMain +=
        "v_normal = u_normalMatrix * mat3(skinMatrix) * weightedNormal;\n";
    } else {vertexShaderMain += "v_normal = u_normalMatrix * weightedNormal;\n";}
    fragmentShader += "varying vec3 v_normal;\n";
  }
  fragmentShader += "varying vec3 v_positionEC;\n";
}

在新的 Model 架构中,官网团队心愿把这两种着色器设计的长处集中起来:将每个着色器划分为一连串的逻辑步骤,称之为“管线阶段(Pipeline Stages)”。每个管线阶段都是一个 GLSL 函数,可在 main 函数中调用。有一些阶段能够通过 #define 宏来启用 / 禁用,然而着色器中的步骤、程序是固定的,这意味着 main 函数就是第一种“超级着色器”的升级版,上面是 ModelFS.glsl 着色器代码的简略摘录:

void main() {
  // Material colors and other settings to pass through the pipelines
  czm_modelMaterial material = defaultModelMaterial();


  // Process varyings and store them in a struct for any stage that needs
  // attribute values.
  ProcessedAttributes attributes;
  geometryStage(attributes);

  // Sample textures and configure the material
  materialStage(material, attributes);

  // If a style was applied, apply the style color
  #ifdef HAS_CPU_STYLE
  cpuStylingStage(material, selectedFeature);
  #endif  

  // If the user provided a CustomShader, run this GLSL code.
  #ifdef HAS_CUSTOM_FRAGMENT_SHADER
  customShaderStage(material, attributes);
  #endif


  // The lighting stage always runs. It either does PBR shading when LIGHTING_PBR
  // is defined, or unlit shading when LIGHTING_UNLIT is defined.
  lightingStage(material);

  // Handle alpha blending
  gl_FragColor = handleAlpha(material.diffuse, material.alpha);
}

后果着色器的各个管线阶段函数,既能够提前内置写好(即“超级着色器”格调),也能够由 JavaScript 代码动静拼接,哪种适合用哪种。

例如,材质管线阶段就应用了一个“超级着色器”,因为 glTF 的材质应用一套固定的材质格局和惟一值(Uniform),参考 MaterialStageFS.glsl。其它的管线阶段,例如因素 ID 管线阶段,就必须依据 glTF 中提供的顶点属性或者纹理的数量大小来动静生成对应的 GLSL 函数体代码。沙盒中的例子 3D Tiles Next Photogrammetry 由 JavaScript 生成的 GLSL 代码片段如下:

// This model has one set of texture coordinates. Other models may have 0 or more of them.
void setDynamicVaryings(inout ProcessedAttributes attributes) {attributes.texCoord_0 = v_texCoord_0;}

// This model has 2 feature ID textures, so two lines of code are generated.
// Other models might store feature IDs in attributes rather than textures so different code
// would be generated.
void initializeFeatureIds(out FeatureIds featureIds, ProcessedAttributes attributes) {featureIds.featureId_0 = czm_unpackUint(texture2D(u_featureIdTexture_0, v_texCoord_0).r);
    featureIds.featureId_1 = czm_unpackUint(texture2D(u_featureIdTexture_1, v_texCoord_0).r);
}

上图:下面提及的歪斜摄影模型的截图

有了这套新的 GLSL 着色器代码设计,就能够适应各种用户的需要,应用 CesiumJS 扩大出不同的用法。而且新的设计更加模块化,因为每个管线阶段都是本人的性能,互不影响。所以能够轻而易举地定义出新的“着色阶段”来减少新的想要的成果。此外,内置的 GLSL 管线阶段代码被存储在独立的 glsl 文件中(在 Shaders/Model 文件夹下),与 JavaScript 代码解耦合。

3. 模型渲染管线

在 CesiumJS 中,筹备渲染模型的 JS 代码就体现出模型的着色器管线构造。上一节提及的管线阶段,被命名为 XXXPipelineStage,作为一种 JavaScript 模块存在。管线的输出和输入是“渲染资源”,是 GPU 行将渲染的图元所需的一系列资源、设定值。渲染资源有很多属性,次要的是:

  • 一个 ShaderBuilder 实例 – 辅助对象,能够逐渐创立出 GLSL 着色器程序
  • VertexAttribute 缓冲数组
  • 一个惟一值对象(uniform map)- 一堆能返回惟一值的函数,集成在一个 JavaScript 对象中
  • 一系列配置 WebGL 的状态值,例如深度检测或反面剔除等

大多数 JavaScript 管线阶段在顶点着色器(例如 DequantizationPipelineStage.js)、片段着色器(例如 LightingPipelineStage.js)或二者均有(例如 GeometryPipelineStage.js)中定义了一个对应的 GLSL 管线阶段函数。不过这并不是强制要求,一些管线阶段则会批改渲染资源的某些局部(例如 AlphaPipelineStage.js)。

管线的指标,是创立出能够发送给 CesiumJS 渲染引擎的绘制指令(DrawCommand)。

想理解更多 CesiumJS 的渲染帧的细节,能够参考这篇文章 Cesium 博客 – CesiumJS 中的图形技术

创立绘制指令的流程如下:

  • 为图元配置管线。用到的管线阶段将组成一个数组,其它没用到的就跳过。参考 ModelRuntimePrimitive.configurePipeline() 办法;
  • 创立一个空的渲染资源对象;
  • 执行管线。将渲染资源对象传入管线阶段对象数组中的每个阶段,每个阶段对象将对渲染资源对象程序作用;
  • 此时渲染资源配置结束,为图元创立一个 ModelDrawCommand 实例;
  • 在每一帧,调用 ModelDrawCommand.pushCommands() 办法,将过后的绘图指令推送到 frameState.commandList 中;ModelDrawCommand 会主动解决 2D、半透明等指令。

对于构建和执行管线的残缺代码,参考 ModelSceneGraph.buildDrawCommands() 办法。

3.1. 管线举例

让咱们看一个例子来深刻探讨管线阶段。首先,思考一个没有灯光、有纹理的模型,这是比较简单的状况,所以管线只有几个阶段。

上图:一个歪斜摄影建筑物模型,根底色彩是事后烘焙的,所以没应用光照

  • 几何管线阶段(GeometryPipelineStage):

    • JavaScript 的工作:将 glTF Primitive 对象的 VertexAttribute 增加到 DrawCommand 对象的 VertexArray 属性中,并将 geometryStage() 函数增加到顶点着色器代码和片元着色器代码中;
    • 顶点着色器的工作:把顶点坐标(position)和法线(normal)从模型坐标系(或模型空间)转换至视坐标系(视空间),并作替换值(varying),最初还作了投影变换,将顶点坐标转换至裁剪坐标(裁剪空间)下,交予 gl_Position
    • 片元着色器的工作:对插值后的法线进行归一化解决
  • 材质管线阶段(MaterialPipelineStage):

    • JavaScript 的工作:将 materialStage() 函数增加到片元着色器代码中,并为根底色纹理定义一个惟一值(uniform),设置 HAS_BASE_COLOR_TEXTURE 宏,以及设置光照模型为 UNLIT
    • 片元着色器的工作:从根底色纹理中采样,并将后果值存到材质构造体中,这个材质构造领会向下呈递给光照管线阶段
  • 光照管线阶段(LightingPipelineStage):

    • JavaScript 的工作:增加 lightingStage() 函数到片元着色器代码中,并给片元着色器代码中的 LIGHTING_UNLIT 宏一个定义;
    • 片元着色器的工作:从上一个阶段,也就是材质管线阶段中取得材质构造体,并利用光照。在这个例子中,无光照模式(UnlitLighting)会返回未经批改的漫反射色彩。如果模型应用 PBR 材质,那么这个阶段会利用 glTF 标准中对应的光照成果。

上图:上述例子的逻辑示意图,每个管线阶段都有 JavaScript 代码更新渲染资源,大部分管线阶段会向顶点着色器、片元着色器增加对应的代码

当初,咱们来看一个应用新性能的例子。应用 glTF 的扩大 EXT_mesh_features 能为每个顶点附加分类信息,而后应用一个 CustomShader 来将顶点的分类信息(译者注:也就是因素款式化)可视化,代码见下:

model.customShader = new Cesium.CustomShader({
  fragmentShaderText: `
  #define ROOF 0
  #define WALL 1
  void fragmentMain(FragmentInput fsInput, inout czm_modelMaterial material) {
    int featureId = fsInput.featureIds.featureId_0;
    if (featureId == ROOF) {material.diffuse *= vec3(1.0, 1.0, 0.0);
    } else if (featureId == WALL) {material.diffuse *= vec4(0.0, 1.0, 1.0);
    }
    // …and similar for other features.
  }
  `
});

可视化后果:

上图:为模型利用 CustomShader 来突出显示各个三维因素

将下面所有的性能都完备,模型渲染管线的完整版即:

  • 几何管线阶段(GeometryPipelineStage):和前一个例子形容的一样
  • 材质管线阶段(MaterialPipelineStage):和前一个例子形容的一样
  • 因素 ID 管线阶段(FeatureIdPipelineStage):

    • JavaScript 的工作:动静生成片元着色器代码,此时 JS 的工作是把所有的因素 ID 集中至一个名为 FeatureIds 的构造体中;在这个例子,只有一个因素 ID 纹理须要解决,在其它的例子中因素 ID 可能存储在顶点属性(VertexAttribute)中;
    • 片元着色器的工作:动静生成的片元着色器会从因素 ID 纹理中读取因素 ID 的值,并存至 FeatureIds 构造体中;
  • 自定义着色器管线阶段(CustomShaderPipelineStage):

    • JavaScript 的工作:测验 CustomShader 并在运行时决定是否须要更新顶点着色器、片元着色器和惟一值对象(UniformMap)。在这个例子中,它(自定义着色器)把开发者传进来的 fragmentMain() 函数增加到片元着色器中;
    • 片元着色器的工作:customShaderStage() 是一个包装函数,它会收集输出参数和材质参数,并调用 fragmentMain(fsInput, material) 函数,所以这意味着材质在光照阶段之前就利用了自定义着色器;
  • 光照管线阶段(LightingPipelineStage):和前一个例子形容的一样

上图:具备自定义着色器的例子含两个额定的阶段,一个用于解决因素 ID,一个用于增加自定义着色器代码

除了上述两个例子列出的管线阶段之外,还有其它的管线阶段,有的是对 glTF 形形色色扩大的实现,例如实例化、网格量化,有些则是对 CesiumJS 特有性能的实现,例如点云衰减、裁剪成果。这些模块化设计的管线阶段,使得在渲染管线增加新的成果更容易了。

4. 与 3DTiles 集成

这次新设计不仅让 glTF 在 CesiumJS 中的渲染变得更迷信,还让 CesiumJS 的 3DTiles 标准与 glTF 的联合更加容易了。

在 3DTiles 中,每个瓦片数据集(tileset)蕴含一棵瓦片空间索引树,每个瓦片可能会援用一个瓦片文件。在 3DTiles 1.0 中,批次 3D 模型(Batched 3D Model,b3dm)格局就是 glTF 的一种封装,它次要增加了一个蕴含每个 3D 因素的属性的批次表(BatchTable);而对于实例三维模型(Instanced 3D Model,i3dm)也蕴含了一个 glTF 模型以及一个实例变换表。

在 CesiumJS 中,这两个瓦片格局的代码实现由两个 Cesium3DTileContent 的子类实现,之前这两个类都基于旧版 Model 类实现。

对于点云类型(pnts),则不应用 glTF,它有本人的代码实现。

上图:在 3DTiles 1.0 中,glTF 文件没有间接被援用,而且点云文件格式的实现与其它两种不同

在下一代的 3DTiles 中(也就是将来的 3DTiles 1.1),瓦片数据集(tileset)将能够间接援用 glTF 格局的内容,即 glb/gltf 文件。旧版的瓦片格局当初被“视作”glTF 格局加一些扩大来解析成 Model 对象,例如:

  • .b3dm 格局被视为 glTF + EXT_structural_metadata 扩大
  • .i3dm 格局被视为 glTF + EXT_mesh_gpu_instancing 扩大
  • .pnts 格局也能够间接当其为 glTF 的原生点格局。

上图:glTF 解析成 Model 对象的路线图,所有的 3DTiles 1.0 瓦片格局也都在运行时进行了降级、兼容转换为新版的 Model 对象

除了简化 Model 架构的代码外,这个新的架构在 3DTiles 的应用中失去了一个高度一致的开发体验,例如自定义着色器对所有的内容都是统一的。

5. 译者的话

早在 3DTiles Next 还在孵化阶段的时候,我就留神到官网在对 Model.js 模块动手脚,然而那时还不具备对 CesiumJS 整体框架理解的能力。

有时候就是那么灵光一现,冲破本人,在翻译、浏览、学习结束 3DTiles 1.1 + CesiumJS 2022 系列后,加上早两年就相熟的 glTF 标准,终于等到了这个全新的 Model 架构公布。

CesiumJS 团队对 glTF 标准是有推动作用的,早年的 glb 叫做 bgltf,最终在 glTF2.0 中才转正,这个就是 CesiumJS 的奉献,其余的奉献也还有,就不细说了。

这个 Model 架构也算是给 glTF、3DTiles 生态打开了新的一页了,迷信的体系,又不失弱小的灵便扩大余地,便于后续降级革新,群里曾经有小伙伴基于此套架构改出了更好看的 PBR 成果。

国内任重道远。

正文完
 0