CesiumJS中新的glTF架构之旅

CesiumJS中新的glTF架构之旅

2022年10月5日

CesiumJS和glTF有着悠久的历史。CesiumJS是最早的glTF加载器之一,早在2012年,glTF还被称为WebGLTF。

此后发生了很多事情:glTF 1.0发布,glTF 1.0内嵌的着色器变成了glTF 2.0的PBR材质,扩展生态系统迅速发展。最近,3D Tiles Next 引入了glTF扩展,用于在每个像素粒度上编码元数据,并允许tilesets直接引用glTF。多年来,我们已经了解到社区如何在实践中使用glTF和3D Tiles,现在我们正在使用这些知识来指导CesiumJS的未来。

我们从最初的glTF loader中获得了很多里程数,但我们已经开始超越它。所以我们开始重新设计,心中有几个目标:

  • 解耦glTF加载和模型渲染

  • 添加对每个顶点和每个像素粒度的元数据的支持

  • 使着色器生成可扩展并支持自定义着色器

  • 当纹理在模型之间共享时,缓存纹理以减少内存使用

  • 创建一个更干净的与3D Tiles集成的格式,如 .pnts point clouds

  • 提高或至少保持加载和渲染性能

虽然Cesium的glTF加载器 Model 的公共API没有改变,但内部结构看起来有很大的不同。

在这里插入图片描述

加载一个 glTF 模型

Model 将其加载和解析代码分离为许多用于资源加载的类,首先是GltfLoader类。这个类负责获取 .glb 或 .gltf 文件,以及任何外部资源,如二进制缓冲区或图像。得到的glTF JSON被解析并转换为内存中的ModelComponents表示。这个对象的结构与JSON类似,但有很多属性会被转换为对应的CesiumJS对象。例如,纹理被转换为纹理实例。还有几个函数和类用于解析EXT_mesh_featuresEXT_structural_metadata扩展的元数据,这些扩展将在接下来的3D Tiles中介绍。

在这里插入图片描述

GltfLoader解析glTF文件并生成内存表示ModelComponents。GltfLoader使用其他几个加载器来完成较小的任务,例如加载纹理或上传顶点缓冲区到GPU。

glTF 允许通过共享资源,来减少存储空间、带宽和处理时间。这可以发生在许多抽象层次上。例如,glTF文件中的两个图元(primitives)可以共享相同的几何缓冲区,但使用不同的材质。或者两个不同的glTF文件可以引用相同的外部图像作为它们的纹理。在运行时,同一个glTF可能在一个场景的多个位置渲染。在所有这些情况下,数据只需要加载一次并重用。

Model 使用全局资源缓存(Global ResourceCache)来存储可以共享的资源,例如纹理,二进制缓冲区和glTF文件的JSON部分。当加载的代码需要某个资源时,首先检查缓存。在缓存命中时(cache hit),引用计数(reference count)加1并返回资源。只有在缓存未命中时,资源才加载到内存中。无论资源是在glTF内部共享、在多个glTF之间共享,还是在同一个glTF的多个副本之间共享,资源都只会被加载到内存中一次。

这种缓存对于加载多个贴图共享相同纹理的3D Tiles tilesets 时特别有用。之前的glTF实现没有纹理的全局缓存,所以新的实现可以在这种情况下显著减少内存使用。

在这里插入图片描述

即使在缓存之后,非常大的场景仍然需要加载许多外部资源。为了确保数据流尽可能高效地传输,CesiumJS使网络请求并行化,并行化的程度达到浏览器限制的数量。

渲染一个模型:着色器优先(shader-first)设计

我们新的加载架构提供了更多的灵活性,我们希望新的渲染方法也同样灵活。渲染glTF模型是一项复杂的任务。glTF规范允许多种材质和其他功能,如动画。此外,CesiumJS还添加了许多运行时特性,例如选择、样式和自定义GLSL着色器。我们需要一个以可维护的方式处理所有这些细节的设计。

在准备用于渲染的模型时,最复杂的部分是生成GLSL着色器程序,因此我们从一开始就仔细研究了这一点。在3D图形中,有两种常见的方法来生成复杂的着色器。

首先是 uber 着色器(uber shader),所有着色器变量都写在一个大型GLSL文件中,不同的预处理器定义用于选择哪些代码在编译时运行。GLSL代码可以存储在单独的文件中,从而与运行时的JavaScript代码解耦。如果着色器的变化是预先知道的,那么这种方法工作得很好。例如,所有的glTF材质都遵循相同的基于物理的渲染(PBR)算法。主要的区别是根据glTF材质设置确定启用哪些纹理和其他制服(uniforms)。

例如,一些模型使用带有纹理的材质;另一些则使用恒定的漫反射颜色。请看下面的例子,它是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中的数据。动态代码允许生成比#ifdef提供的更高级的着色器逻辑。然而,它也会导致大量GLSL和JavaScript代码交织在一起,难以阅读。因此,任何动态代码生成都应该小心保持其可维护性。下面的代码片段是从旧实现的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 架构,我们想要创建两种选择的混合。我们将每个着色器分成一系列逻辑步骤,称为流水线阶段。流水线的每个阶段都是一个可以在main()函数中调用的函数。有些阶段可以通过#define指令来启用或禁用,但着色器中步骤的顺序是固定的。这意味着main()函数是uber shader 方法的一个很好的候选者。下面是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);
}

着色器中的各个流水线阶段可以使用当前更合适的 uber shader 或动态生成代码。例如,material pipeline阶段使用uber shader,因为glTF材质使用一组固定的可能的 textures and uniforms(见MaterialStageFS.glsl)。其他管道阶段,如特征ID管道阶段,必须根据特定glTF中提供的属性/纹理数量动态生成函数体。例如,下面是运行3D Tiles Next Photogrammetry Sandcastle 时生成的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/folder),与JavaScript代码解耦。

模型渲染管线

在CesiumJS中准备渲染的模型的JavaScript代码与模型着色器的流水线结构非常相似。管道阶段被形式化为JavaScript模块,命名规范为XxxPipelineStage。管道的输入和输出是渲染资源,一组GPU资源和设置,这些是为准备渲染图元所需的。渲染资源包括许多属性,最明显的是:

  • 一个ShaderBuilder 实例——这是一个辅助对象,可以增量地构建GLSL着色程序。

  • 顶点属性缓冲区的数组

  • 一个uniform map -一个回调函数的集合,用于设置着色器的uniform值

  • 用于配置渲染设置的WebGL标志(flags),如深度测试或背面剔除

大多数JavaScript流水线阶段都会在顶点着色器(例如 DequantizationPipelinStage.js)、片段着色器(例如LightingPipelinStage.js)或两者都定义相应的GLSL流水线阶段函数(例如GeometryPipelinStage.js)。然而,这不是要求。一些JS管道阶段会修改渲染资源的其他部分(例如AlphaPipelinStage.js)。

管道的目标是生成可以发送到CesiumJS的渲染引擎的绘制命令。要了解有关CesiumJS如何渲染一帧的更多信息,请参阅这篇博客文章。创建绘制命令的算法如下:

1、为图元(primitive)配置管道(pipeline)。只使用相关的管道阶段填充数组,其他阶段可以跳过。参考 ModelRuntimePrimitive.configurePipeline ()

2、创建一个空的render resources对象。

3、执行管道(pipeline)。将渲染资源传递到数组中的每个阶段,它将被修改。

4、现在渲染资源已经配置好了,为这个图元创建一个ModelDrawCommand实例。

5、对每一帧,调用ModelDrawCommand.pushCommands(),将适当的绘制命令推送到frameState.commandList。ModelDrawCommand自动处理2D、半透明、轮廓和其他派生命令。

有关构建和执行管道的完整代码,请参阅ModelSceneGraph.buildDrawCommands()

流水线/渲染管道示例

让我们更详细地看一下这些管道阶段。首先,让我们考虑一个没有光照的、有纹理的模型。这是渲染的最简单的情况之一,因此管道只包含几个阶段。

在这里插入图片描述

  • GeometryPipelineStage:

JavaScript:将原始图元 (primitive) 的顶点属性添加到绘制命令的顶点数组中。它还为顶点着色器和片段着色器添加了geometryStage()函数。

顶点着色器:转换位置和法线从模型坐标到视图坐标设置变化。还将位置转换为clip坐标,用于计算gl_Position

片段着色器:对插值的法线进行归一化

  • MaterialPipelineStage:

JavaScript:在片段着色器中添加materialStage()函数,为基本颜色纹理定义一个统一的颜色,在片段着色器中设置HAS_BASE_COLOR_TEXTURE定义,并设置照明模型为UNLIT

片段着色器:从基本颜色纹理中采样颜色,并将此颜色存储在material结构中,将传递到照明管道阶段

  • LightingPipelineStage:

JavaScript:将lightingStage()函数添加到片段着色器中,并在片段着色器中定义LIGHTING_UNLIT。

片段着色器:从material阶段获取material结构并应用光照。在这种情况下,unlit照明返回未修改的漫反射颜色。如果模型使用基于物理的渲染(PBR)材料,则此阶段将应用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.
  }
  `
});

这样处理的结果如下所示:

在这里插入图片描述

考虑到上述所有特性,模型渲染管道将如下所示:

  • GeometryPipelineStage:和之前的例子一样

  • MaterialPipelineStage:与之前的例子相同

  • FeatureIdPipelineStage:

JavaScript:动态生成片段着色器代码,将所有 Feature IDs聚集到一个名为FeatureIds的结构中。在这种情况下,只有一个特征ID纹理要处理。在其他情况下,特征IDs可以存储在顶点属性中。

FragmentShader:动态生成的代码从纹理中读取特征(feature)ID值,并将它们存储在FeatureIds结构体中。

  • CustomShaderPipelineStage:

JavaScript:检查自定义着色器并确定如何根据需要更新顶点着色器、片段着色器和统一映射。在这个例子中,它给片段着色器添加了用户定义的fragmentMain()回调函数。

customShaderStage()是一个包装函数,它收集输入参数和material,并调用fragmentMain(fsInput, material)。这在它到达照明阶段之前修改了材料。

LightingPipelineStage:和之前的例子一样

在这里插入图片描述

除了上面列出的那些,还有其他几个管道阶段。一些实现了glTF扩展,如实例化和网格量化,另一些提供了cesiumjs特定的功能,如点云衰减和裁剪平面。这些管道阶段的模块化设计使得在渲染管道中添加新功能很容易。

3D Tiles 融合

这次重新设计不仅使我们在CesiumJS中渲染glTF模型的方法现代化,而且还使我们有机会简化3D Tiles和glTF系统一起工作的方式。

在3D Tiles中,每个tileset包含一个 tiles 的空间树(spatial tree),每个瓦片可能包含一个内容文件。在3D Tiles 1.0中,Batched 3D Model (.b3dm)格式是一个glTF资产的包装器,它添加了一个包含每个特征元数据的批处理表。实例化3D模型(.i3dm)格式包含一个glTF资产和一个实例转换列表。在CesiumJS中,这两种格式的实现由两个Cesium3DTileContent子类组成,它们都使用旧的Model类。同时,点云(.pnts)格式没有使用glTF,因此它有一个单独的代码路径。

在这里插入图片描述

在 3D Tiles Next 版本中,tilesets 将可以直接使用glTF资产(. glTF /. glb )作为tile 的内容。旧的tile格式现在可以表示为glTF资产加上扩展。.b3dm文件成为一个glTF与EXT_structural_metadata扩展存储元数据类似于旧的批处理表。.i3dm文件使用EXT_mesh_gpu_instancing和EXT_structural_metadata作为批处理表。即使是.pnts也可以用POINTS原始模式表示为glTF模型。

直接使用glTF资产(assets)作为tile的内容简化了CesiumJS的实现。有多个模型加载器,但运行时细节都由单个 Model 处理。

在这里插入图片描述

除了简化代码之外,这种新的架构在使用3D Tiles时提供了更一致的体验。例如,自定义着色器适用于所有内容类型。

去试试吧

新的 Model 架构自9月1.97发布以来就已经可用。请参阅3D Models Sandcastle中的一个简单示例。要尝试一些新的功能,如CustomShader,请参阅CustomShader Model Sandcastle,和其他Sandcastles 中不同 Tab 页的 3D Tiles。

猜你喜欢

转载自blog.csdn.net/yinweimumu/article/details/134607704