引子
接觸Cesium一年有余了,期間靠胡吃海塞吸收了很多有用的、沒用的知識和技術,感覺有點消化不良,今天終於有時間來梳理一下了。之前一直搞二維的,對三維技術只能算是半路出家,不敢寫太深的原理性文章,以免誤人子弟,但寫寫心得還是可以的。我想寫一個Cesium深入淺出系列,即將深刻的道理用淺顯的語言表述出來,縱觀大部分的技術類文章,應該沒幾個能真正的做到這一點吧。所以,雖然深入淺出被用濫了,但我依然選擇了它。我希望我的文章不管入的有多深,但出來一定是很淺的。這讓我想到了白居易,寫詩追求淺顯易懂,不止是讓文人墨客品鑒,也要讓市井百姓能輕易讀懂,他的詩反而因此更加廣為流傳,這就是所謂意境大於形式吧。那么閑話不多說了,讓我們來深深的入吧!
預期效果
使用三維白模直接加載到Cesium中的效果太過朴素,就算是根據屬性設置不同的顏色還是丑,歸根結底是顏色渲染太單一,我們可以使用漸變色來渲染一下,效果會立馬提升一個檔次,再配合泛光效果,可以出來點數字城市的意思。先上一張效果圖:
實現原理
先聲明一下,以下內容可能僅適配小白,如感不適,敬請繞道。
言歸正傳,如果想要讓單調的白模變得不那么單調,我們首先想到的是采用貼圖的方式。這種方式很簡單,只要找一張漸變色的貼圖貼上就可以了,不過缺點也是顯而易見的,貼圖的工作是要在數據處理的時候就要做的,一旦裝修好了也就定型了,如果你想再恢復成毛坯,對不起了您吶,再去處理一遍數據。所以我們不想要這種先天的效果,而是通過后天努力去實現。可是要怎么實現呢?為了更好地解釋原理,我們有必要做一點功課了。
Entity
在Cesium中,數據按加載方式可以籠統地分為兩種類型,即Entity和Primitive。Entity即實體,引用API原文:Entity instances aggregate multiple forms of visualization into a single high-level object,意思是說實體的實例是將多種形式的可視化效果聚合到一個高級的對象中,這個高級對象就是Entity。請注意了,官方大大說了這個東西很高級,高級意味着更實用、更好用,但同時也意味着體積會更臃腫,占用更多的系統資源,稍微做過點功課的童鞋知道,海量數據是不能用Entity方式來加載的,它能卡爆你的內存。如果想要高性能,那么你還得了解一下Primitive。
Primitive
Primitive,即圖元,Mesh的基本單位,這個是圖形學里面的解釋,Cesium中的Primitive感覺就英文的字面意思,我暫時叫它原始體吧,既然是原始的,那說明這種格式更接近於底層,比實體有着更高的性能。當然了它的缺點就是易用性差點,有過經驗的小伙伴都知道,Primitive用起來要比Entity麻煩一點。
Material
上面我們為什么要先介紹數據類型呢,那是因為不同的數據類型貼材質的方式是不同的。下面來介紹下材質,即Material,就像我們裝修房子用的裝修材料一樣,客廳地板用瓷磚材質,卧室用木地板材質,牆面用油漆材質,當然咯,你也可以用牆紙,類似我們前面提到的貼圖。查看API之后我們知道,Cesium中的材質有不少,不過先不着急研究哪個材質適合我們,我們要先來研究一下怎么把材質貼到模型上。
Entity方式
1 // 創建一個有洞的多邊形,並填充藍色材質 2 var polygon = viewer.entities.add({ 3 name: "Blue polygon with holes", 4 polygon: { 5 hierarchy: { 6 positions: Cesium.Cartesian3.fromDegreesArray([ 7 -99.0, 30.0, 8 -85.0, 30.0, 9 -85.0, 40.0, 10 -99.0, 40.0, 11 ]), 12 holes: [{ 13 positions: Cesium.Cartesian3.fromDegreesArray([ 14 -97.0, 31.0, 15 -97.0, 39.0, 16 -87.0, 39.0, 17 -87.0, 31.0, 18 ]) 19 }] 20 }, 21 material: Cesium.Color.BLUE.withAlpha(0.5), 22 height: 0, 23 outline: true 24 } 25 }); 26 27 // 改變材質 28 // 方式一:通過類型創建材質 29 polygon.material = Cesium.Material.fromType('Color'); 30 polygon.material.uniforms.color = new Cesium.Color(1.0, 1.0, 0.0, 1.0); 31 32 // 方式二:創建一個默認材質 33 polygon.material = new Cesium.Material(); 34 35 // 方式三:通過Fabric方式 36 polygon.material = new Cesium.Material({ 37 fabric : { 38 type : 'Color', 39 uniforms : { 40 color : new Cesium.Color(1.0, 1.0, 0.0, 1.0) 41 } 42 } 43 });
Primitive方式
1 // 畫一個橢圓形,並使用西洋棋盤材質填充 2 var instance = new Cesium.GeometryInstance({ 3 geometry : new Cesium.EllipseGeometry({ 4 center : Cesium.Cartesian3.fromDegrees(-100.0, 20.0), 5 semiMinorAxis : 500000.0, 6 semiMajorAxis : 1000000.0, 7 rotation : Cesium.Math.PI_OVER_FOUR, 8 vertexFormat : Cesium.VertexFormat.POSITION_AND_ST 9 }), 10 id : 'ellipse' 11 }); 12 scene.primitives.add(new Cesium.Primitive({ 13 geometryInstances : instance, 14 appearance : new Cesium.EllipsoidSurfaceAppearance({ 15 material : Cesium.Material.fromType('Checkerboard') 16 }) 17 }));
上述代碼只是很簡單的例子,不過也基本能說明材質是如何應用到模型上的。那么現在問題來了,3dtiles究竟屬於哪種數據類型?感覺哪種都不像,怎么辦?看來我們得繼續做點功課了。
Cesium3DTileset
Cesium3DTileset,即3dtiles在Cesium中的數據表現形式。現在看一下3dtiles的數據是怎么加載的。
1 var tileset = scene.primitives.add(new Cesium.Cesium3DTileset({ 2 url : 'http://localhost:8002/tilesets/Seattle/tileset.json', 3 skipLevelOfDetail : true, 4 baseScreenSpaceError : 1024, 5 skipScreenSpaceErrorFactor : 16, 6 skipLevels : 1, 7 immediatelyLoadDesiredLevelOfDetail : false, 8 loadSiblings : false, 9 cullWithChildrenBounds : true 10 }));
原來3dtiles也是通過Primitive方式加載的啊,但是仔細看上面代碼,並沒有像普通Primitive那樣設置材質。這也印證了上面提到過的疑問,它看似Primitive又不像Primitive,又翻了一遍API,發現確實沒有設置材質的入口,僅有類似專題圖的設置樣式的入口。
1 tileset.style = new Cesium.Cesium3DTileStyle({ 2 color : { 3 conditions : [ 4 ['${Height} >= 100', 'color("purple", 0.5)'], 5 ['${Height} >= 50', 'color("red")'], 6 ['true', 'color("blue")'] 7 ] 8 }, 9 show : '${Height} > 0', 10 meta : { 11 description : '"Building id ${id} has height ${Height}."' 12 } 13 });
通過設置樣式可以改變3dtiles顏色,甚至可以根據屬性為模型設置不同的顏色,這讓我們的白模稍微好看了一點,但也僅此而已了,我們想實現的是漸變顏色,而不是單調的純色。我估計到了這里大部分小白就懵逼了,因為這個問題連谷哥和度娘都不能告訴你答案,難道我們就這樣放棄了?不能夠,去問問大牛吧,然后大牛很不屑的甩你一個詞:shader!好吧,shader是what,不懂WebGL的小白又雙叒叕得去做功課了。
Shader
shader,即着色器,分為頂點着色器(Vertex Shader)、片元着色器(Fragment Shader)、幾何着色器(Geometry shader)、計算着色器(Compute shader)、細分曲面着色器(Tessellation or hull shader),其中可編程的是頂點着色器和片元着色器。至於它們的定義網上可以找到很多,但對於小白來講看完還是一臉懵逼,我們需要一種通俗易懂的解釋,這才符合深入淺出的精髓。在知乎上找到一段解釋,感覺還不錯:
當我們在屏幕上繪制或顯示一些物體時,這些物體的顯示形式是圖元(Primitive)或者網格(Mesh),比如游戲中一個幾何模型角色或一個貼在網格上的紋理角色,比如我們做陰影效果時先繪制網格再計算陰影,比如一個發射物體發射前需要先繪制該物體外形網格。這些物體都可歸結為網格,它可被分解為圖元,即圖元是網格的基本單位。圖元有三角形、直線或點。當我們在屏幕上畫一個三角形時,首先要繪制頂點,因為網格由頂點組成,此時就要用到頂點着色器(Vertex shader),將需要到頂點信息給頂點着色器,以顯示頂點信息;其次是在這些頂點組成的區域之間填充顏色,此時用到像素着色器(Pixel shader)或片元着色器(Fragment shader),片段(Fragment)有助於定義像素的最終顏色。
終極原理
看到這里,小伙伴們肯定着急了,都說了那么多了,原理到底是個啥,究竟該如何下手呢。其實原理就是着色器編程,但是Cesium並沒有為3dtiles的着色器編程的入口,那么只有一個辦法了,那就是改源碼。改源碼似乎不是個多好的方案,源碼那么復雜,看着頭疼,而且每次更新版本都得再改一次,但是從另一個角度來講,使用開源平台怎么能不會改源碼呢,這也是必備技能吧。其實需要改的地方很簡單,找到着色器編程部分,插入我們想要的語句就可以了。
具體實現
先說一下我修改的源碼版本,Cesium-1.68\Build\CesiumUnminified\Cesium.js,因為是直接引用Cesium進行編程的,所以是選用的Build版的,如果是Source版的應該大同小異,可以自行研究。
要修改的地方共計4處,都是位於function generateTechnique$1(gltf, material, materialIndex, generatedMaterialValues, primitiveByMaterial, options) 方法里面,我會給出修改的行數,但只能做參考,我也給出了上下文,可以使用關鍵字來搜索。
第一處和第二處:約第117579行
1 vertexShader += 'attribute vec3 a_position;\n'; 2 if (hasNormals) { 3 vertexShader += 'varying vec3 v_positionEC;\n'; 4 } 5 6 // 第一處添加 7 vertexShader += 'varying vec3 v_helsing_position;\n'; 8 9 // Morph Target Weighting 10 vertexShaderMain += ' vec3 weightedPosition = a_position;\n'; 11 if (hasNormals) { 12 vertexShaderMain += ' vec3 weightedNormal = a_normal;\n'; 13 // 第二處添加 14 vertexShaderMain += ' v_helsing_position = a_position;\n'; 15 }
第三處:約第117825行
1 fragmentShader += '#ifdef USE_IBL_LIGHTING \n'; 2 fragmentShader += 'uniform vec2 gltf_iblFactor; \n'; 3 fragmentShader += '#endif \n'; 4 fragmentShader += '#ifdef USE_CUSTOM_LIGHT_COLOR \n'; 5 fragmentShader += 'uniform vec3 gltf_lightColor; \n'; 6 fragmentShader += '#endif \n'; 7 8 // 第三處添加 9 fragmentShader += 'varying vec3 v_helsing_position;\n'; 10 11 fragmentShader += 'void main(void) \n{\n'; 12 fragmentShader += fragmentShaderMain;
第四處:約第118135行
1 if (defined(alphaMode)) { 2 if (alphaMode === 'MASK') { 3 fragmentShader += ' if (baseColorWithAlpha.a < u_alphaCutoff) {\n'; 4 fragmentShader += ' discard;\n'; 5 fragmentShader += ' }\n'; 6 fragmentShader += ' gl_FragColor = vec4(color, 1.0);\n'; 7 } else if (alphaMode === 'BLEND') { 8 fragmentShader += ' gl_FragColor = vec4(color, baseColorWithAlpha.a);\n'; 9 } else { 10 fragmentShader += ' gl_FragColor = vec4(color, 1.0);\n'; 11 } 12 } else { 13 fragmentShader += ' gl_FragColor = vec4(color, 1.0);\n'; 14 } 15 16 // 第四處添加 17 fragmentShader += ' float helsing_p = v_helsing_position.z / 20.0;\n'; 18 fragmentShader += ' gl_FragColor *= vec4(helsing_p, helsing_p, helsing_p, 1.0);\n'; 19 20 fragmentShader += '}\n';
改完之后看看效果吧,一定很有成就感吧。什么?跟效果圖不一樣,出來的是黑白顏色的?沒關系,還記得上面提到的樣式么,修改成你想要的任何顏色吧。
還是原理
本想此篇到此就結束了,但細想一下,上面並沒有介紹為什么要那么改,不講原理直接講操作就是耍流氓啊。沒有WebGL基礎的小伙伴看到上面的代碼略微有點懵圈,貌似能看懂,又好像看不懂,其實那是着色器語言(GLSL),下面再簡單了解一下着色器語言。由於內容較多,就不深入展開理論來講了,就以上述的代碼做例子,逐行解釋一下。
第一處
1 vertexShader += 'varying vec3 v_helsing_position;\n';
從名字我們知道vertexShader是頂點着色器,這是源碼中已經定義好的變量,用來存儲頂點着色器的代碼,我們在其中插入了一行定義語句。varying是修飾符,它的主要作用是頂點着色器和片元着色器之間的數據傳遞,比如顏色或紋理坐標,而且片元着色器只能以只讀的方式使用這個變量,不能修改其中的數據。簡單來講就是,頂點着色器定義,片元着色器使用。同為修飾符的還有const、attribute、uniform、centorid varying、invariant、in、out、inout等,后面涉及到了再講。vec3是基本類型中的一種,在GLSL中除了在大部分編程語言中常見的int、float、bool等基本類型外,還有一些自己特有的類型,如vec2、vec3、vec4、mat2、mat3、mat4、sampler2D等。vec3指的是三維浮點數向量,定義方式如vec3 v = vec3(1.0, 1.0, 1.0)。什么?你不知道什么叫向量?那你初中物理一定沒好好學。向量在物理學中稱作矢量,在數學中稱作向量,向量是指既有大小又有方向的量,如速度、 加速度、力、位移等;與之對應的是標量,又稱為稱“無向量”,是只具有數值大小而沒有方向的量。從上述定義的變量名可以看出,我們定義的這個變量是用來存儲坐標信息的。
第二處
1 vertexShaderMain += ' v_helsing_position = a_position;\n';
這一處沒什么好講的,就是給我們定義的變量傳值,把坐標信息傳遞過來。
第三處
1 fragmentShader += 'varying vec3 v_helsing_position;\n';
有了上面的經驗,根據變量名我們知道現在正在修改的是片元着色器。上面講過了,varying修飾符的作用是,頂點着色器定義,片元着色器使用。所以在這里我們要再定義一次變量,好給片元着色器調用。
第四處
1 fragmentShader += ' float helsing_p = v_helsing_position.z / 20.0;\n'; 2 fragmentShader += ' gl_FragColor *= vec4(helsing_p, helsing_p, helsing_p, 1.0);\n';
這是最后一處了,也是核心代碼所在地了。在片元着色器中添加上這幾行代碼就能實現篇首的效果圖,是不是很神奇?歡迎收看走近科學,下面讓我帶你們揭秘這些神奇的符號。
上面一股腦兒出現了好多神秘符號,有必要把它們都羅列出來,搞懂之后再看邏輯了。
基礎類型:
float:浮點型標量。
vec4:四維浮點數向量。
內置變量:
gl_FragColor:表示當前片元的顏色,是vec4 類型的,變量名是以gl_開頭的,我們可以想到它是GL的內置變量。
czm_frameNumber:看到名字是以czm_開頭的,有童鞋就舉手了:它是Cesium內置變量!是的,沒錯。Cesium也內置了很多變量,都是以czm_開頭的,這些都是可以直接使用的,想了解更多的小伙伴請點這里。czm_frameNumber是float類型的,從字面理解就是當前的幀數,類似動畫的幀。
內置函數:
fract(x):獲取x的小數部分。
sina(angle):正弦函數,單位是弧度。
abs(x):返回x的絕對值。
clamp(x, minVal, maxVal):使返回值限制在minVal和maxVal之間,即min(max(x, minVal), maxVal)。
step(edge, x):如果x<edge,返回0.0,否則返回1.0。
代碼解釋:
先看第二句代碼,gl_FragColor *= vec4(helsing_p, helsing_p, helsing_p, 1.0),這句其實很好理解,就是給gl_FragColor賦值,也就是給當前片元顏色賦值,p的值通過前面兩句求出。
再看第一句代碼,float helsing_p = v_helsing_position.z / 20.0,我們可以看出p值是跟高度(z值)有關的,模型上體現出來的效果就是樓宇越高越亮。我們看到了這里的高度除以了20,為什么要除以20呢?這其實是個經驗值,它是根據平均樓宇的高度而定的,如果你的模型是農村地區的一片小平房,就要把這個值調低了,要不然就是灰蒙蒙的一片了。
小結
終於寫完了!我們最后來回顧一下,看看是不是非常簡單,幾行代碼就搞定了所謂很牛X的功能。正所謂難了不會,會了不難。當然最要緊的是掌握原理,知其然也要知其所以然。另外,好多網友跟我說修改源碼的方式不太靈活,尤其是Cesium版本更新一次就得改一次,好像確實有點麻煩哦。好在萬能的群友解決了這個問題,不過因為不是我的原創我就不在這里發了。我的文章目的還是要讓人了解些原理,只要掌握了原理就可以不拘泥於實現方式了。