Threejs開發3D地圖實踐總結
前段時間連續上了一個月班,加班加點完成了一個3D攻堅項目。也算是由傳統web轉型到webgl圖形學開發中,坑不少,做了一下總結分享。
1、法向量問題
法線是垂直于我們想要照亮的物體表面的向量。法線代表表面的方向因此他們為光源和物體的交互建模中具有決定性作用。每一個頂點都有一個關聯的法向量。
如果一個頂點被多個三角形共享,共享頂點的法向量等于共享頂點在不同的三角形中的法向量的和。N=N1+N2;

所以如果不做任何處理,直接將3維物體的點傳遞給BufferGeometry,那么由于法向量被合成,經過片元著色器插值后,就會得到這個黑不溜秋的效果
我的處理方式使頂點的法向量保持唯一,那么就需要在共享頂點處,拷貝一份頂點,并重新計算索引,是的每個被多個面共享的頂點都有多份,每一份有一個單獨的法向量,這樣就可以使得每個面都有一個相同的顏色
2、光源與面塊顏色
開發過程中設計給了一套配色,然而一旦有光源,面塊的最終顏色就會與光源混合,顏色自然與最終設計的顏色大相徑庭。下面是Lambert光照模型的混合算法。
而且產品的要求是頂面保持設計的顏色,側面需要加入光源變化效果,當對地圖做操作時,側面顏色需要根據視角發生變化。那么我的處理方式是將頂面與側面分別繪制(創建兩個Mesh),頂面使用MeshLambertMaterial的emssive屬性設置自發光顏色與設計顏色保持一致,也就不會有光照效果,側面綜合使用Emssive與color來應用光源效果。
3、POI標注
Three中創建始終朝向相機的POI可以使用Sprite類,同時可以將文字和圖片繪制在canvas上,將canvas作為紋理貼圖放到Sprite上。但這里的一個問題是canvas圖像將會失真,原因是沒有合理的設置sprite的scale,導致圖片被拉伸或縮放失真。
問題的解決思路是要保證在3d世界中的縮放尺寸,經過一系列變換投影到相機屏幕后仍然與canvas在屏幕上的大小保持一致。這需要我們計算出屏幕像素與3d世界中的長度單位的比值,然后將sprite縮放到合適的3d長度。
4、點擊拾取問題
webgl中3D物體繪制到屏幕將經過以下幾個階段
所以要在3D應用做點擊拾取,首先要將屏幕坐標系轉化成ndc坐標系,這時候得到ndc的xy坐標,由于2d屏幕并沒有z值所以,屏幕點轉化成3d坐標的z可以隨意取值,一般取0.5(z在-1到1之間)。
- function fromSreenToNdc(x, y, container) {
- return {
- x: x / container.offsetWidth * 2 - 1,
- y: -y / container.offsetHeight * 2 + 1,
- z: 1
- };
- }
- function fromNdcToScreen(x, y, container) {
- return {
- x: (x + 1) / 2 * container.offsetWidth,
- y: (1 - y) / 2 * container.offsetHeight
- };
- }
然后將ndc坐標轉化成3D坐標:
- ndc = P * MV * Vec4
- Vec4 = MV-1 * P -1 * ndc
這個過程在Three中的Vector3類中已經有實現:
- unproject: function () {
- var matrix = new Matrix4();
- return function unproject( camera ) {
- matrix.multiplyMatrices( camera.matrixWorld, matrix.getInverse( camera.projectionMatrix ) );
- return this.applyMatrix4( matrix );
- };
- }(),
將得到的3d點與相機位置結合起來做一條射線,分別與場景中的物體進行碰撞檢測。首先與物體的外包球進行相交性檢測,與球不相交的排除,與球相交的保存進入下一步處理。將所有外包球與射線相交的物體按照距離相機遠近進行排序,然后將射線與組成物體的三角形做相交性檢測。求出相交物體。當然這個過程也由Three中的RayCaster做了封裝,使用起來很簡單:
- mouse.x = ndcPos.x;
- mouse.y = ndcPos.y;
- this.raycaster.setFromCamera(mouse, camera);
- var intersects = this.raycaster.intersectObjects(this._getIntersectMeshes(floor, zoom), true);
5、性能優化
隨著場景中的物體越來越多,繪制過程越來越耗時,導致手機端幾乎無法使用。
在圖形學里面有個很重要的概念叫“one draw all”一次繪制,也就是說調用繪圖api的次數越少,性能越高。比如canvas中的fillRect、fillText等,webgl中的drawElements、drawArrays;所以這里的解決方案是對相同樣式的物體,把它們的側面和頂面統一放到一個BufferGeometry中。這樣可以大大降低繪圖api的調用次數,極大的提升渲染性能。
這樣解決了渲染性能問題,然而帶來了另一個問題,現在是吧所有樣式相同的面放在一個BufferGeometry中(我們稱為樣式圖形),那么在面點擊時候就無法單獨判斷出到底是哪個物體(我們稱為物體圖形)被選中,也就無法對這個物體進行高亮縮放處理。我的處理方式是,把所有的物體單獨生成物體圖形保存在內存中,做面點擊的時候用這部分數據來做相交性檢測。對于選中物體后的高亮縮放處理,首先把樣式面中相應部分裁減掉,然后把選中的物體圖形加入到場景中,對它進行縮放高亮處理。裁剪方法是,記錄每個物體在樣式圖形中的其實索引位置,在需要裁切時候將這部分索引制零。在需要恢復的地方在把這部分索引恢復成原狀。
6、面點擊移動到屏幕中央
這部分也是遇到了不少坑,首先的想法是:
面中心點目前是在世界坐標系內的坐標,先用center.project(camera)得到歸一化設備坐標,在根據ndc得到屏幕坐標,而后根據面中心點屏幕坐標與屏幕中心點坐標做插值,得到偏移量,在根據OribitControls中的pan方法來更新相機位置。這種方式最終以失敗告終,因為相機可能做各種變換,所以屏幕坐標的偏移與3d世界坐標系中的位置關系并不是線性對應的。
最終的想法是:
我們現在想將點擊面的中心點移到屏幕中心,屏幕中心的ndc坐標永遠都是(0,0)我們的觀察視線與近景面的焦點的ndc坐標也是0,0;也就是說我們要將面中心點作為我們的觀察點(屏幕的中心永遠都是相機的觀察視線),這里我們可以直接將面中心所謂視線的觀察點,利用lookAt方法求取相機矩陣,但如果這樣簡單處理后的效果就會給人感覺相機的姿態變化了,也就是會感覺并不是平移過去的,所以我們要做的是保持相機當前姿態將面中心作為相機觀察點。
回想平移時我們將屏幕移動轉化為相機變化的過程是知道屏幕偏移求target,這里我們要做的就是知道target反推屏幕偏移的過程。首先根據當前target與面中心求出相機的偏移向量,根據相機偏移向量求出在相機x軸和up軸的投影長度,根據投影長度就能返推出應該在屏幕上的平移量。
- this.unprojectPan = function(deltaVector, moveDown) {
- // var getProjectLength()
- var element = scope.domElement === document ? scope.domElement.body : scope.domElement;
- var cxv = new Vector3(0, 0, 0).setFromMatrixColumn(scope.object.matrix, 0);// 相機x軸
- var cyv = new Vector3(0, 0, 0).setFromMatrixColumn(scope.object.matrix, 1);// 相機y軸
- // 相機軸都是單位向量
- var pxl = deltaVector.dot(cxv)/* / cxv.length()*/; // 向量在相機x軸的投影
- var pyl = deltaVector.dot(cyv)/* / cyv.length()*/; // 向量在相機y軸的投影
- // offset=dx * vector(cx) + dy * vector(cy.project(xoz).normalize)
- // offset由相機x軸方向向量+相機y軸向量在xoz平面的投影組成
- var dv = deltaVector.clone();
- dv.sub(cxv.multiplyScalar(pxl));
- pyl = dv.length();
- if ( scope.object instanceof PerspectiveCamera ) {
- // perspective
- var position = scope.object.position;
- var offset = new Vector3(0, 0, 0);
- offset.copy(position).sub(scope.target);
- var distance = offset.length();
- distance *= Math.tan(scope.object.fov / 2 * Math.PI / 180);
- // var xd = 2 * distance * deltaX / element.clientHeight;
- // var yd = 2 * distance * deltaY / element.clientHeight;
- // panLeft( xd, scope.object.matrix );
- // panUp( yd, scope.object.matrix );
- var deltaX = pxl * element.clientHeight / (2 * distance);
- var deltaY = pyl * element.clientHeight / (2 * distance) * (moveDown ? -1 : 1);
- return [deltaX, deltaY];
- } else if ( scope.object instanceof OrthographicCamera ) {
- // orthographic
- // panLeft( deltaX * ( scope.object.right - scope.object.left ) / scope.object.zoom / element.clientWidth, scope.object.matrix );
- // panUp( deltaY * ( scope.object.top - scope.object.bottom ) / scope.object.zoom / element.clientHeight, scope.object.matrix );
- var deltaX = pxl * element.clientWidth * scope.object.zoom / (scope.object.right - scope.object.left);
- var deltaY = pyl * element.clientHeight * scope.object.zoom / (scope.object.top - scope.object.bottom);
- return [deltaX, deltaY];
- } else {
- // camera neither orthographic nor perspective
- console.warn( 'WARNING: OrbitControls.js encountered an unknown camera type - pan disabled.' );
- }
- }
7、2/3D切換
23D切換的主要內容就是當相機的視線軸與場景的平面垂直時,使用平行投影,這樣用戶只能看到頂面給人的感覺就是2D視圖。所以要根據透視的視錐體計算出平行投影的世景體。
因為用戶會在2D、3D場景下做很多操作,比如平移、縮放、旋轉,要想無縫切換,這個關鍵在于將平行投影與視錐體相機的位置、lookAt方式保持一致;以及將他們放大縮小的關鍵點:distance的比例與zoom來保持一致。
平行投影中,zoom越大代表六面體的首尾兩個面面積越小,放大越大。
8、3D中地理級別
地理級別實際是像素跟墨卡托坐標系下米的對應關系,這個有通用的標準以及計算公式:
- r=6378137
- resolution=2*PI*r/(2^zoom*256)
各個級別中像素與米的對應關系如下:
- resolution zoom 2048 blocksize 256 blocksize scale(dpi=160)
- 156543.0339 0 320600133.5 40075016.69 986097851.5
- 78271.51696 1 160300066.7 20037508.34 493048925.8
- 39135.75848 2 80150033.37 10018754.17 246524462.9
- 19567.87924 3 40075016.69 5009377.086 123262231.4
- 9783.939621 4 20037508.34 2504688.543 61631115.72
- 4891.96981 5 10018754.17 1252344.271 30815557.86
- 2445.984905 6 5009377.086 626172.1357 15407778.93
- 1222.992453 7 2504688.543 313086.0679 7703889.465
- 611.4962263 8 1252344.271 156543.0339 3851944.732
- 305.7481131 9 626172.1357 78271.51696 1925972.366
- 152.8740566 10 313086.0679 39135.75848 962986.1831
- 76.4370283 11 156543.0339 19567.87924 481493.0916
- 38.2185141 12 78271.51696 9783.939621 240746.5458
- 19.1092571 13 39135.75848 4891.96981 120373.2729
- 9.5546285 14 19567.87924 2445.984905 60186.63645
- 4.7773143 15 9783.939621 1222.992453 30093.31822
- 2.3886571 16 4891.96981 611.4962263 15046.65911
- 1.1943286 17 2445.984905 305.7481131 7523.329556
- 0.5971643 18 1222.992453 152.8740566 3761.664778
- 0.2985821 19 611.4962263 76.43702829 1880.832389
- 0.1492911 20 305.7481131 38.21851414 940.4161945
- 0.0746455 21
- 0.0373227 22
3D中的計算策略是,首先需要將3D世界中的坐標與墨卡托單位的對應關系搞清楚,如果已經是以mi來做單位,那么就可以直接將相機的投影屏幕的高度與屏幕的像素數目做比值,得出的結果跟上面的ranking做比較,選擇不用的級別數據以及比例尺。注意3D地圖中的比例尺并不是在所有屏幕上的所有位置與現實世界都滿足這個比例尺,只能說是相機中心點在屏幕位置處的像素是滿足這個關系的,因為平行投影有近大遠小的效果。
9、poi碰撞
由于標注是永遠朝著相機的,所以標注的碰撞就是把標注點轉換到屏幕坐標系用寬高來計算矩形相交問題。至于具體的碰撞算法,大家可以在網上找到,這里不展開。下面是計算poi矩形的代碼