原文: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_features
、EXT_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
; - 片元着色器的工作:对插值后的法线进行归一化解决
- JavaScript 的工作:将 glTF Primitive 对象的 VertexAttribute 增加到 DrawCommand 对象的 VertexArray 属性中,并将
-
材质管线阶段(MaterialPipelineStage):
- JavaScript 的工作:将
materialStage()
函数增加到片元着色器代码中,并为根底色纹理定义一个惟一值(uniform),设置HAS_BASE_COLOR_TEXTURE
宏,以及设置光照模型为UNLIT
- 片元着色器的工作:从根底色纹理中采样,并将后果值存到材质构造体中,这个材质构造领会向下呈递给光照管线阶段
- JavaScript 的工作:将
-
光照管线阶段(LightingPipelineStage):
- JavaScript 的工作:增加
lightingStage()
函数到片元着色器代码中,并给片元着色器代码中的LIGHTING_UNLIT
宏一个定义; - 片元着色器的工作:从上一个阶段,也就是材质管线阶段中取得材质构造体,并利用光照。在这个例子中,无光照模式(UnlitLighting)会返回未经批改的漫反射色彩。如果模型应用 PBR 材质,那么这个阶段会利用 glTF 标准中对应的光照成果。
- JavaScript 的工作:增加
上图:上述例子的逻辑示意图,每个管线阶段都有 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)
函数,所以这意味着材质在光照阶段之前就利用了自定义着色器;
- JavaScript 的工作:测验
- 光照管线阶段(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 成果。
国内任重道远。