CesiumJS 技術博客:glTF 模型(Model)加載新架構


原文:https://cesium.com/blog/2022/10/05/tour-of-the-new-gltf-architecture-in-cesiumjs/

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 的調用層面來說,幾乎沒有改變,但是底層付出的努力可以說是釜底抽薪了。

image

上圖爲著名的 COLLADA 鴨子模型,在 2012 年轉換成 glTF 並加載的效果

1. 加載一個 glTF 模型

Model 類將模型的加載、解析職能分離到一些“資源加載類”。

首先是 GltfLoader 類,這個類負責獲取 .glb.gltf 文件,以及連帶的任何外部資源,例如二進制文件、貼圖圖像文件等;glTF 的 JSON 部分經由一系列轉換後,會生成一個 ModelComponents 對象,這個對象的結構和 glTF 自己的 JSON 部分很相似,但很多屬性都由 CesiumJS 自己的對象來填充。例如,glTF 紋理對象被轉換爲 CesiumJS 的 Texture 實例,還有幾個輔助函數和類用於解析來自下一代 3DTiles 引入的 EXT_mesh_featuresEXT_structural_metadata 擴展,以獲取更豐富的信息。

image

上圖:GltfLoader 解析 glTF 文件,將其載入內存中,生成 ModelComponents 對象。GltfLoader 還會用到其它幾個子 Loader 分解任務,例如加載紋理或上載頂點緩衝到 GPU

glTF 允許通過共享資源來降低存儲空間、網絡傳輸帶寬壓力、處理時間。這個機制可以發生在很多個抽象層之間。例如,兩個 glTF 的 primitive 對象可以共享同一個幾何緩衝區,但是用不同的材質對象。或者,兩個不同的 glTF 文件引用相同的紋理貼圖文件。在運行時,同一個 glTF 在場景中可能會渲染多處;在上述這些情況中,數據資源應當只需要加載一次,然後儘可能地重複利用。

現在,CesiumJS 的新 Model 架構(類)使用一個全局的 ResourceCache 類來存儲那些需要被共享的資源,例如紋理、二進制緩衝、glTF 的 JSON 部分等。當加載程序代碼需要一個資源的時候,首先就會檢查緩存。一旦命中緩存,那麼被緩存的資源就計數+1,並返回該資源。只有緩存中沒有這份所需的資源時,纔會加載它到內存中。無論某個資源是單個 glTF 內部、多個 glTF 共享或單個 glTF 的多份副本共享,它只加載一次到內存中。

這種緩存機制對加載 3DTiles 是有幫助的,這些瓦片有可能會共享紋理貼圖。之前的 glTF 加載架構沒有爲紋理設置全局緩存,現在具備這個機制後可以大大減少內存的佔用。

image

上圖:在 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);
}

image

上圖:上面提及的傾斜攝影模型的截圖

有了這套新的 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. 管線舉例

讓我們看一個例子來深入討論管線階段。首先,考慮一個沒有燈光、有紋理的模型,這是比較簡單的情況,所以管線只有幾個階段。

image

上圖:一個傾斜攝影建築物模型,基礎顏色是預先烘焙的,所以沒使用光照

  • 幾何管線階段(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 規範中對應的光照效果。

image

上圖:上述例子的邏輯示意圖,每個管線階段都有 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.
  }
  `
});

可視化結果:

image

上圖:爲模型應用 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):和前一個例子描述的一樣

image

上圖:具備自定義着色器的例子含兩個額外的階段,一個用於處理要素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,它有自己的代碼實現。

image

上圖:在 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 的原生點格式。

image

上圖: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 效果。

國內任重道遠。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章