3DTiles 與 I3S 是競爭關係,可是比起生態開放性、資料定義的靈活性與易讀性來說,3DTiles 比 I3S 好太多了。由於資料生產工具的開發者水平參差不齊,且資料並不存在極致的、萬能的優化方法,故 3DTiles 1.0 時代的一些工具可能導致的資料渲染質量問題,讓 3DTiles 的效能、顯示效果頗受爭議。
隨著 CesiumJS 模型新架構的逐漸成型,下一代的 3DTiles 首先以 1.0 的擴充套件項測試使用,待日後時機成熟,會將 1.0 的規範標記為過時,甚至直接廢棄,直接將這些擴充套件項作為 1.1 版本的核心定義使用(估計還挺久的)。
下一代 3DTiles 明確了這套規範的職能,即 更自由的顯示效果可能性、更專注地三維空間索引效能、更強大的資訊融合能力。
嘮叨有點長,開始講解,本文著重介紹的是“3DTiles”在 CesiumJS 中運作流程本身,而不是瓦片檔案的解析(解析有興趣的可以看本系列文章的上一篇,或者看舊版實現)、瓦片的空間排程演算法、3DTiles 著色器設計。
1. 3DTiles 資料集的型別
3DTiles 1.0 規範允許異構資料共存於一個資料集上。3D 瓦片只是空間劃分的單元,並不是該塊三維空域內的具體三維物體。這些三維物體被稱作“瓦片內容”。
1.0 允許存在 7 種瓦片內容,它們的檔案字尾名是:
- b3dm,批次三維模型,該瓦片檔案內建一個 glTF 模型檔案,應儘可能在資料生產時優化此 glTF 的繪製批次
- i3dm,例項三維模型,允許內嵌在 i3dm 檔案內的 glTF 模型在 WebGL 種繪製多例項
- pnts,點雲
- cmpt,複合格式,即前三者的混合體,合併細碎瓦片內容檔案成一個,減少網路請求
- vctr,向量瓦片,未正式釋出,本篇不討論
- json,這種叫做擴充套件資料集(
ExternalTileset
),即允許瓦片空域內再巢狀一個子 3DTiles - 空瓦片,即瓦片無內容
而 1.0 的擴充套件項,也就是下一代標準增加了一種瓦片格式:
- glb/gltf,也就是直接將 glTF 模型檔案作為瓦片內容檔案
值得注意的是,社群提案中,延申了 geojson 為瓦片內容檔案,也就是說,在未來也許有可能引入更多的瓦片格式,但是能不能成為官方標準還不一定,暫且以 1.0 + 下一代的 glTF 格式為主要解說點。
而 3DTiles 一切的入口,都是從一個 json 檔案開始的,這個檔名稱是隨意的。
2. 建立瓦片樹
Cesium3DTileset
類代表了一個 3DTiles 資料集,每個資料集總是有一個根瓦片(Cesium3DTile
)。
Cesium3DTileset
同舊版 Model
、新的 ModelExperimental
類一樣,也是一種“似 Primitive
(PrimitiveLike)”類,所以允許直接加入 scene.primitives
容器中。
下面給出一些簡單的程式碼:
import {
Cesium3DTileset
} from 'cesium'
// 最常規的載入
const t1 = viewer.scene.primitives.add(new Cesium3DTileset({
url: 'http://localhost/static/tilesets/t1/tileset.json'
}))
// 使用新模型架構載入具備下一代資料標準的資料集
const t2 = viewer.scene.primitives.add(new Cesium3DTileset({
url: 'http://localhost/static/tilesets/t2/entry.json',
enableModelExperimental: true
}))
2.1. 請求入口檔案
一旦 new 了 Cesium3DTileset
,那麼就會穿過接近 1000 行的建構函式(未壓縮),走到最後的非同步請求:
function Cesium3DTileset(options) {
const that = this;
let resource;
this._readyPromise = Promise.resolve(options.url)
.then(function (url) {
/* ... */
resource = Resource.createIfNeeded(url);
/* ... */
return Cesium3DTileset.loadJson(resource);
})
.then(function (tilesetJson) {
/* ... */
return processMetadataExtension(that, tilesetJson);
})
.then(function (tilesetJson) {
/* ... */
that._root = that.loadTileset(resource, tilesetJson);
/* ... */
return that;
})
}
在第 1 個 then 中,呼叫 Resource
類相關靜態方法,發起網路請求,得到的結果就是 tileset.json
的物件,然後向下傳遞;
第 2 個 then 是處理 3DTILES_metadata
擴充套件用的,這個不是本文的核心內容:3DTiles 在 CesiumJS 中的運作流程,所以略過。
第 3 個 then,呼叫 Cesium3DTileset
例項的 loadTileset
方法,載入整棵 3DTiles 樹,也就是下一小節的內容。
2.2. 建立樹結構
接上一小節,請求到入口檔案並反序列化為 JavaScript 物件後,就由 Cesium3DTileset.prototype.loadTileset
方法開始建立整棵樹了。
我一開始納悶為什麼這種載入方法要作為例項方法,而不是靜態方法,後來看到 ExternalTileset
的載入過程時才知道這樣設計的意圖。
先看程式碼吧:
Cesium3DTileset.prototype.loadTileset = function (/**/) {
const asset = tilesetJson.asset;
if (!defined(asset)) {
throw new RuntimeError("Tileset must have an asset property.");
}
if (
asset.version !== "0.0" &&
asset.version !== "1.0" &&
asset.version !== "1.1"
) {
throw new RuntimeError(
"The tileset must be 3D Tiles version 0.0, 1.0, or 1.1"
);
}
if (defined(tilesetJson.extensionsRequired)) {
Cesium3DTileset.checkSupportedExtensions(
tilesetJson.extensionsRequired
);
}
/* ... */
const rootTile = makeTile(this, resource, tilesetJson.root, parentTile);
if (defined(parentTile)) {
parentTile.children.push(rootTile);
rootTile._depth = parentTile._depth + 1;
}
const stack = [];
stack.push(rootTile);
while (stack.length > 0) {
/* ... */
const children = tile._header.children;
if (defined(children)) {
const length = children.length;
for (let i = 0; i < length; ++i) {
/* ... */
const childTile = makeTile(this, resource, childHeader, tile);
tile.children.push(childTile);
stack.push(childTile);
}
}
/* ... */
}
return rootTile;
}
建立樹結構主要有三塊內容:
- 檢驗資料合法性
- 建立根瓦片物件
- 從根瓦片開始廣度優先搜尋整個資料集,建立出所有的
Cesium3DTile
檢驗資料合法性是近一年來加強的,對版本號、擴充套件項做了嚴格的要求。
老實說,這就是 1.0 的效能隱患,如果不使用
ExternalTileset
,而把大量的瓦片定義在tileset.json
上,那麼這個廣度優先搜尋就非常消耗 CPU 的計算資源。下一代的 3DTiles 使用隱式瓦片擴充套件解決了這個效能弱點。
無論是建立根瓦片,還是建立子瓦片物件,都要經過模組內的 makeTile
函式:
function makeTile(tileset, baseResource, tileHeader, parentTile) {
const hasImplicitTiling =
defined(tileHeader.implicitTiling) ||
hasExtension(tileHeader, "3DTILES_implicit_tiling");
if (hasImplicitTiling) {
/* ... */
}
return new Cesium3DTile(tileset, baseResource, tileHeader, parentTile);
}
其實這個函式的主要作用還是分辨下一代 3DTiles 的隱式瓦片用的,也就是判斷是否有 3DTILES_implicit_tiling
擴充套件,如果沒有,直接返回 new 出來的 Cesium3DTile
物件即可。
來看看隱式瓦片這個邏輯分支做了什麼:
if (hasImplicitTiling) {
const implicitTileset = new ImplicitTileset(/* ... */);
const rootCoordinates = new ImplicitTileCoordinates({/* ... */});
const contentUri = implicitTileset.subtreeUriTemplate.getDerivedResource(
templateValues: rootCoordinates.getTemplateValues(),
).url;
const deepCopy = true;
const tileJson = clone(tileHeader, deepCopy);
tileJson.contents = [
{
uri: contentUri,
},
];
delete tileJson.content;
delete tileJson.extensions;
const tile = new Cesium3DTile(
tileset, baseResource, tileJson, parentTile
);
tile.implicitTileset = implicitTileset;
tile.implicitCoordinates = rootCoordinates;
return tile;
}
其實也是返回一個 Cesium3DTile
,但是它多了倆專門用於隱式瓦片擴充套件的欄位,分別是 ImplicitTileset
和 ImplicitCoordinates
。
待所有瓦片物件建立完畢後,那麼 Cesium3DTileset
物件也就算建立完成了,此時也只有這棵樹的結構,沒有瓦片內容。瓦片內容是根據當前檢視狀態,隨 Scene
的單幀更新過程去選取、下載、解析,進而建立 DrawCommand 的。
2.3. 瓦片快取機制帶來的能力
3DTiles 也是有快取功能的,由 Cesium3DTilesetCache
類完成快取,它的例項是 Cesium3DTileset
的一個成員欄位 _cache
。
在本文下一節將會講到 3DTiles 的更新過程,有三大步驟,其中有一個叫做“請求瓦片”的步驟,這一步是非同步的,用到了 ES6 Promise API,當瓦片內容檔案請求、解析完成後,在 tile.contentReadyPromise
的 then 鏈中就使用下面這個函式將瓦片物件新增到快取池中:
function handleTileSuccess(tileset, tile) {
return function (content) {
/* ... */
if (!tile.hasTilesetContent && !tile.hasImplicitContent) {
/* ... */
tileset._cache.add(tile);
}
}
}
這個快取機制有什麼用呢?
翻遍原始碼,能在即將在下面介紹的遍歷器中、解除安裝瓦片的過程中用到這個快取池,這樣就能免於再次搜尋哪些瓦片需要被解除安裝了。
此處的快取機制只是快取瓦片物件的引用。對於在記憶體中瓦片檔案的快取,請看本系列文章的上一篇,我介紹了
ModelExperimental
新架構中的快取機制,那裡快取的 ResourceLoader 上才會有資源資料。
3. 瓦片樹的遍歷更新
3.1. 三個大步驟
伴隨著 Scene
的幀更新過程,Cesium3DTileset
也一起進入更新、建立 DrawCommand 的隊伍中。
很快就從 Cesium3DTileset.prototype.update
方法進入到 Cesium3DTileset.prototype.updateForPass
方法。先點題,updateForPass
方法會進入到模組內的函式 update
內,由如下三個大步驟完成 3DTiles 樹上的瓦片的選擇、請求解析、更新:
方法 updateForPass
裡頭有一個值得注意的變數,那就是傳進來的引數:來自 frameState 的 tilesetPassState
,型別是 Cesium3DTilePassState
,它身上攜帶了一個欄位:
- pass,是
Cesium3DTilePass
列舉,指示 3DTiles 更新時是哪一道通道的
這個欄位用於在更新時獲取 passOptions
:
Cesium3DTileset.prototype.updateForPass = function (
frameState,
tilesetPassState
) {
const pass = tilesetPassState.pass;
/* ... */
const passOptions = Cesium3DTilePass.getPassOptions(pass);
/* ... */
}
在普通渲染更新過程中,欄位 pass
的值就是 Cesium3DTilePass.RENDER
,此時 passOptions
根據原始碼可以得知:
const Cesium3DTilePass = {
RENDER: 0,
PICK: 1,
SHADOW: 2,
PRELOAD: 3,
PRELOAD_FLIGHT: 4,
REQUEST_RENDER_MODE_DEFER_CHECK: 5,
MOST_DETAILED_PRELOAD: 6,
MOST_DETAILED_PICK: 7,
NUMBER_OF_PASSES: 8,
};
const passOptions = new Array(Cesium3DTilePass.NUMBER_OF_PASSES);
passOptions[Cesium3DTilePass.RENDER] = Object.freeze({
traversal: Cesium3DTilesetTraversal,
isRender: true,
requestTiles: true,
ignoreCommands: false,
});
/* 其他 passOptions */
Cesium3DTilePass.getPassOptions = function (pass) {
return passOptions[pass];
};
passOptions
會透過 Cesium3DTileset.js
模組內的函式 update
,一直傳遞到瓦片內容的選擇、請求、更新這幾個流程:
// Cesium3DTileset.prototype.updateForPass 中
if (this.show || ignoreCommands) {
this._pass = pass;
tilesetPassState.ready = update(
this,
frameState,
passStatistics,
passOptions
);
}
這個 update
函式大致可以分這 3 個流程,圖已經在本小節開頭給到了:
function update(tileset, frameState, passStatistics, passOptions) {
/* ... */
const ready = passOptions.traversal.selectTiles(tileset, frameState);
if (passOptions.requestTiles) {
requestTiles(tileset);
}
updateTiles(tileset, frameState, passOptions);
/* ... */
return ready;
}
不過,在講這 3 個流程進行講解之前,還得提一下 passOptions
上的 traversal
成員。
3.2. 遍歷器
上一小節的 passOptions
來自 Cesium3DTilePass.js
模組,內部定義的若干個 passOption 中,只有兩種 traversal
的值,即:
Cesium3DTilesetTraversal
Cesium3DTilesetMostDetailedTraversal
這兩個靜態類作用於 update
函式的第一個重要步驟,也就是選擇瓦片。
passOptions
上這個 traveral
被稱作“遍歷器”。設計這兩個類,是因為 3DTiles 瓦片的空間排程選擇較為複雜,獨立到一個類中。
我對全程式碼進行了搜尋,發現用到 Cesium3DTilesetMostDetailedTraversal
的邏輯分支條件是使用射線求交拾取相關的 API 時,才會用到這個“詳盡遍歷器”,大多數時候用的還是普通的遍歷器 Cesium3DTilesetTraversal
。
3.3. 選擇瓦片
現在,把視線從遍歷器上返回 Cesium3DTileset.js
模組內的 update
函式中,一句簡單的程式碼就啟動了瓦片的選擇:
// Cesium3DTileset.js
function update(/* ... */) {
/* ... */
const ready = passOptions.traversal.selectTiles(tileset, frameState);
/* ... */
return ready;
}
現在明確瓦片選擇的目的:把符合當前 3DTiles 資料集上各種優化引數的前提下,選出當前視角下要用於載入、解析(如果未載入和未解析),並繼續沿著更新流程建立 DrawCommand 的 Cesium3DTile,掛載到 Cesium3DTileset
物件的 _requestTiles
這個陣列成員上。
3.2 小節指出了大多數時候 passOptions.traversal
是 Cesium3DTilesetTraversal
。呼叫 traversal.selectTiles()
方法的主要流程可由下面的流程示意圖給出:
更新瓦片資訊是第一步,此更新非“更新瓦片的內容資料”,只是更新瓦片物件(Cesium3DTile
)的狀態資訊,主要是可見性計算。
第二步是依據前一步更新的狀態,進行從根瓦片到底的遍歷(此處有三個邏輯分岔,見原始碼),這一步就是最核心的排程演算法;
第三步就是為選出來的瓦片計算其優先值,優先值越高的,越先被載入、渲染。
由於排程演算法並不是本文的目的,所以止步到這一層我認為已足夠,感興趣如何計算 Tile 的可見性、如何被選擇,優先順序如何計算的讀者,可以按這一層繼續往下追蹤原始碼。
3.4. 請求並解析瓦片內容
接上一步,被選中的瓦片已經存至 Cesium3DTileset
物件的 _requestTiles
陣列成員上了,並計算了優先值,即 Cesium3DTile
物件的 _priority
私有成員上,是一個普通的數字。
緊接著,作用域回到 Cesium3DTileset.js
模組內的 update
函式裡頭,繼續執行模組內的 requestTiles
函式:
// Cesium3DTileset.js -> function update()
if (passOptions.requestTiles) {
requestTiles(tileset);
}
這個 requestTiles
函式只做了兩件事:根據優先值排序,並請求瓦片內容:
function requestTiles(tileset, isAsync) {
const requestedTiles = tileset._requestedTiles;
const length = requestedTiles.length;
requestedTiles.sort(sortRequestByPriority);
for (let i = 0; i < length; ++i) {
requestContent(tileset, requestedTiles[i]);
}
}
那麼進入到 requestContent
函式中,主要的程式碼就這幾個:
function requestContent(tileset, tile) {
/* ... */
const attemptedRequests = tile.requestContent();
/* ... */
tile.contentReadyToProcessPromise
.then(addToProcessingQueue(tileset, tile))
.catch(/* ... */);
tile.contentReadyPromise
.then(handleTileSuccess(tileset, tile))
.catch(/* ... */);
}
即發起內容請求,併為瓦片物件上的 contentReadyToProcessPromise
和 contentReadyPromise
這兩個 Promise 註冊 resolve 和 reject 的回撥函式。
至此,當前幀的大流程已經基本完成,即選擇瓦片、發出請求瓦片內容。有人說請求完了應該要解析啊?是要解析沒錯,ES6 Promise API 又派上了用場。
從上面的兩個 Promise 可以看出,瓦片內容 - 也就是 glTF
瓦片、b3dm/i3dm/pnts/cmpt
等瓦片資料檔案請求下來後,還要經過 contentReadyToProcessPromise
中的程式碼進行處理的。
那麼 contentReadyToProcessPromise
又是什麼時候,由誰建立的呢?順著 Cesium3DTile.js
模組中的 requestSingleContent
函式或者 requestMultipleContents
函式,你可以看到這個 Promise 的建立(以其一舉例):
function requestSingleContent(tile) {
const resource = tile._contentResource.clone();
/* ... */
const promise = resource.fetchArrayBuffer();
const contentReadyToProcessPromise = promise.then(function (arrayBuffer) {
/* ... */
const content = makeContent(tile, arrayBuffer);
/* ... */
tile._content = content;
tile._contentState = Cesium3DTileContentState.PROCESSING;
return content;
});
tile._contentReadyToProcessPromise = contentReadyToProcessPromise;
tile._contentReadyPromise = contentReadyToProcessPromise
.then(function (content) {/**/})
.then(function (content) {/**/})
.catch(/**/);
return 0;
}
可見,請求到 ArrayBuffer 後,進入 makeContent
函式進一步處理瓦片內容,生成對應的 Content 類例項。requestMultipleContents
函式就稍微複雜一些。
用程式碼流程圖來說明,比程式碼文字好一些:
可見,CesiumJS 對下一代 3DTiles 中“多內容瓦片(1.0 版本中即 3DTILES_multiple_contents
擴充套件)”還是做了分叉的,對每一個 content 發起請求,留下其請求 Promise,然後使用 Promise.all
併發處理;當每個 Promise resolve 後,呼叫 createInnerContent
函式,對請求下來的 ArrayBuffer
資料,進入簡單工廠 Cesium3DTileContentFactory
分支,建立具體的瓦片內容物件。
如果是單個瓦片內容,如上文所述,走的是 requestSingleContent
函式,當內容檔案的請求 Promise resolve 後,呼叫 makeContent
方法,同樣進入 Cesium3DTileContentFactory
工廠中對應的分支,建立具體的瓦片內容物件。
如果是 glb/gltf
,且開啟了 tileset.enableModelExperimental
為 true
,那麼就能看到上一篇熟悉的 ModelExperimental
的建立了。b3dm
、i3dm
等也同理。
subt
、subtreeJson
是下一代 3DTiles 中隱式瓦片(1.0 版本中的3DTILES_implicit_tiling
擴充套件)中子樹的可見性資料,詳見 3DTiles 相關資料。
別忘了,不管是單內容瓦片執行 makeContent
後,還是多內容瓦片執行 createInnerContent
後,也就是請求到瓦片內容檔案、解析成具體內容類後,都會留下 contentReadyToProcessPromise
、contentReadyPromise
兩個 Promise,供進一步處理。
進一步處理什麼呢?此時該選擇的瓦片選好了,該請求的瓦片請求到了,也解析了,當然就是要 處理成 DrawCommand,供 Renderer 模組去渲染。在說建立 DrawCommand 之前,我先把本節講解的“三大步驟”中最後一個步驟,也就是 updateTiles
函式講完,隨後再說是如何建立每個被選中的瓦片的 DrawCommand 的。
3.5. 更新瓦片並建立 DrawCommand
三大步驟中的最後一個步驟:
// Cesium3DTileset.js
function update(tileset, frameState, passStatistics, passOptions) {
/* ... */
updateTiles(tileset, frameState, passOptions);
/* ... */
}
function updateTiles(tileset, frameState, passOptions) {
/* ... */
const selectedTiles = tileset._selectedTiles;
const selectedLength = selectedTiles.length;
/* ... */
let i;
let tile;
for (i = 0; i < selectedLength; ++i) {
/* ... */
tile.update(tileset, frameState, passOptions);
/* ... */
}
/* ... */
}
這個步驟沒有太多很關鍵的行為,只是更新一些狀態資訊、可有可無的效果(例如裁剪面、除錯資訊等),最終呼叫 tile._content
的 update
方法,進而建立 DrawCommand 或其它的 Command,總之很常規。
至此,3DTiles 三大步驟,從選擇瓦片,到發起請求解析瓦片內容,到更新瓦片狀態並隨之建立內容在當前幀的 DrawCommand,一切都順利。期間,ES6 Promise API 配合各個資料物件上的狀態機制,來判斷在當前幀是否該做什麼 —— 譬如某個資料狀態得是 READY
,才有資格建立 DrawCommand。
3.6. prePassesUpdate 也能建立 DrawCommand
承 3.4 小節,瓦片內容檔案請求下來、解析後,相關 Promise 的 then 鏈最終扔給 Cesium3DTile
物件的 _contentReadyToProcessPromise
成員,隨後繼續第三個大步驟,更新瓦片的狀態後建立 DrawCommand,結束當前幀的戰鬥。
但是,我發現 Cesium3DTileset
這貨有那麼一丟丟不一樣,它有兩條路線可以建立 DrawCommand。
回憶本系列文章第二篇的內容,也就是 Scene 渲染 Primitive、建立出 DrawCommand 的內容,建立 DrawCommand 是在 下面這段函式呼叫中執行的:
function updateAndRenderPrimitives(scene) {
const frameState = scene._frameState;
/* ... */
scene._primitives.update(frameState);
/* ... */
}
也就是 PrimitiveCollection
會觸發 Primitive
或似 Primitive(例如 Cesium3DTileset
、Model
等)的更新,進而建立出 DrawCommand。
注意,Scene 會在 render 之前走一遍 prePassesUpdate
:
Scene.prototype.render = function (time) {
/* ... */
tryAndCatchError(this, prePassesUpdate);
if (shouldRender) {
/* ... */
tryAndCatchError(this, render);
}
/* ... */
}
function prePassesUpdate(scene) {
/* ... */
const primitives = scene.primitives;
primitives.prePassesUpdate(frameState);
/* ... */
}
也就是會呼叫 Cesium3DTileset.prototype.prePassesUpdate
,最終會呼叫 Cesium3DTile.prototype.process
方法:
Cesium3DTile.prototype.process = function (tileset, frameState) {
/* ... */
this._content.update(tileset, frameState);
/* ... */
};
這時,對 Tile 上的 content 進行 update
,也就是繼續進入建立 DrawCommand 的過程,與 3.5 小節的最終目的一致了。
建立 DrawCommand 並不一定是 Primitive.prototype.update 發起的。更新 Primitive 可能是 Scene.js 模組下的 render 函式發起的,也有可能是 prePassesUpdate 函式發起的。所以,似 Primitive 的 postPassesUpdate 方法也有可能建立 DrawCommand。當然,目前也只有 Cesium3DTileset 擁有 postPassesUpdate,可見 3DTiles 的渲染優先順序之高。
3.7. 自定義著色器
自定義著色器是 ModelExperimental
新架構帶來的 API,即 CustomShader API
,在發文時,Cesium 1.95 還是需要顯式指定使用新架構,才能使用這個自定義著色器,官方沙盒中也有相關的程式碼。
這裡簡單提一下它的作用過程。
自定義著色器雖然定義在 Cesium3DTileset
例項上,但是作用卻是在 ModelExperimental
上,見 ModelExperimental3DTileContent
:
ModelExperimental3DTileContent.prototype.update = function (
tileset,
frameState
) {
const model = this._model;
/* ... */
model.customShader = tileset.customShader;
model.update(frameState);
};
於是,你就能在 ModelExperimental.prototype.update
方法中看到自定義著色器是如何更新場景圖結構物件的了:
function updateCustomShader(model, frameState) {
if (defined(model._customShader)) {
model._customShader.update(frameState);
}
}
ModelExperimental.prototype.update = function (frameState) {
/* ... */
// A custom shader may have to load texture uniforms.
updateCustomShader(this, frameState);
/* ... */
};
具體的內容還是得到 CustomShader.prototype.update
方法裡看。在著色器方面的實現上,用的就是上一篇提到的“階段”技術,選擇性地在著色器程式碼中增加的。
// ModelExperimentalVS.glsl
void main()
{
// ...
Metadata metadata;
metadataStage(metadata, attributes);
#ifdef HAS_CUSTOM_VERTEX_SHADER
czm_modelVertexOutput vsOutput = defaultVertexOutput(attributes.positionMC);
customShaderStage(vsOutput, attributes, featureIds, metadata);
#endif
// ...
}
片元著色器上也有類似的,customShaderStage
函式在 CustomShaderStageVS/FS.glsl
檔案中。
3.8. 樣式引擎
CesiumJS 使用 Cesium3DTileStyle
相關 API 來實現 3DTiles 的樣式化。用法不贅述,有專門的文件:[點我](https://github.com/CesiumGS/3d-tiles/tree/main/specification/Styling|3D Tiles Styling language)
在這裡列出,主要是明確它的作用方式:
// Cesium3DTileset.js
function updateTiles(tileset, frameState, passOptions) {
tileset._styleEngine.applyStyle(tileset);
/* ... */
}
顯然,是在三大步驟的最後一個步驟應用的樣式。
Cesium3DTileStyleEngine.prototype.applyStyle = function (tileset) {
const tiles = styleDirty
? tileset._selectedTiles
: tileset._selectedTilesToStyle;
const length = tiles.length;
for (let i = 0; i < length; ++i) {
const tile = tiles[i];
if (tile.lastStyleTime !== lastStyleTime) {
const content = tile.content;
/* ... */
content.applyStyle(this._style);
/* ... */
}
}
};
content.applyStyle
只是簡單地將 _style
傳遞給 content 例項,最終還是隨 content 例項的 update
方法應用到 DrawCommand 上的。
Cesium3DTileStyle
既可以應用於 3DTiles,也可以應用於ModelExperimental
。它條件樣式語言的作用前提是,在 3DTiles /模型中存在 3D 要素表,這是在製作資料時就必須寫入的。
3.9. 其它
篇幅原因,有一些相對簡單又零碎,或者不屬於本文關注的內容,例如事件機制、裁剪平面、幾何誤差等就不再展開了,以後可以出一些單文來講。
4. 本文總結
其實本文沒寫什麼很深入的內容,只把建立樹、處理瓦片的全流程,以及一些零碎點提煉了出來,希望對讀者有幫助。
截至發文,3DTiles 已經應用了有六七年了,也看到了 Cesium 團隊為此付出的努力。
不好看?卡頓?確實有點,但是已經在努力了。下一代的 3DTiles 真的值得期待!
我認為,3DTiles 規範只是一種大規模空間三維資料的組織指導資料。它本身沒有指導你怎麼製作 LOD,也沒有告訴你該如何把你的業務需求(分層分戶、單體化、點選查詢)如何塞到瓦片裡,這都需要資料生產開發者的不懈努力,把 GPUPicking、Batch、資料調優手段都用起來,那麼 3DTiles 與 glTF 才能煥發出強大的能力,CesiumJS 作為一個前端執行時,它在調優上已經做得很不錯了。
簡單總結如下:
- 亮點:快取機制
- 難點:選擇排程演算法
- 架構設計優點:平穩的接入了下一代 3DTiles 的同時還相容了 1.0 版本
至此,CesiumJS 原始碼解讀系列已經接近尾聲,還有一篇關於資源處理和網路請求、多執行緒的文章,下篇見。