前段時間連續上了一個月班,加班加點完成了一個3D攻堅專案。也算是由傳統web轉型到webgl圖形學開發中,坑不少,做了一下總結分享。
1、法向量問題
3、POI標註
Three中建立始終朝向相機的POI可以使用Sprite類,同時可以將文字和圖片繪製在canvas上,將canvas作為紋理貼圖放到Sprite上。但這裡的一個問題是canvas影像將會失真,原因是沒有合理的設定sprite的scale,導致圖片被拉伸或縮放失真。
問題的解決思路是要保證在3d世界中的縮放尺寸,經過一系列變換投影到相機螢幕後仍然與canvas在螢幕上的大小保持一致。這需要我們計算出螢幕畫素與3d世界中的長度單位的比值,然後將sprite縮放到合適的3d長度。
所以要在3D應用做點選拾取,首先要將螢幕座標系轉化成ndc座標系,這時候得到ndc的xy座標,由於2d螢幕並沒有z值所以,螢幕點轉化成3d座標的z可以隨意取值,一般取0.5(z在-1到1之間)。
1 2 3 4 5 6 7 8 9 10 11 12 13 |
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 }; } |
1 2 3 4 5 6 7 8 9 10 11 12 |
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做了封裝,使用起來很簡單:
1 2 3 4 5 6 |
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、麵點選移動到螢幕中央
這部分也是遇到了不少坑,首先的想法是:
最終的想法是:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 |
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來保持一致。
1 2 |
r=6378137 resolution=2*PI*r/(2^zoom*256) |
各個級別中畫素與米的對應關係如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
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矩形的程式碼
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 |
export function getPoiRect(poi, zoomLevel, wrapper) { let style = getStyle(poi.styleId, zoomLevel); if (!style) { console.warn("style is invalid!"); return; } let labelStyle = getStyle(style.labelid, zoomLevel); if (!labelStyle) { console.warn("labelStyle is invalid!"); return; } if (!poi.text) { return; } let charWidth = (TEXTPROP.charWidth || 11.2) * // 11.2是根據測試得到的估值 (labelStyle.fontSize / (TEXTPROP.fontSize || 13)); // 13是得到11.2時的fontSize // 返回2d座標 let x = 0;//poi.points[0].x; let y = 0;//-poi.points[0].z; let path = []; let icon = iconSet[poi.styleId]; let iconWidh = (icon && icon.width) || 32; let iconHeight = (icon && icon.height) || 32; let multi = /\//g; let firstLinePos = []; let textAlign = null; let baseLine = null; let hOffset = (iconWidh / 2) * ICONSCALE; let vOffset = (iconHeight / 2) * ICONSCALE; switch(poi.direct) { case 2: { // 左 firstLinePos.push(x - hOffset - 2); firstLinePos.push(y); textAlign = 'right'; baseLine = 'middle'; break; }; case 3: { // 下 firstLinePos.push(x); firstLinePos.push(y - vOffset - 2); textAlign = 'center'; baseLine = 'top'; break; }; case 4: { // 上 firstLinePos.push(x); firstLinePos.push(y + vOffset + 2); textAlign = 'center'; baseLine = 'bottom'; break; }; case 1:{ // 右 firstLinePos.push(x + hOffset + 2); firstLinePos.push(y); textAlign = 'left'; baseLine = 'middle'; break; }; default: { firstLinePos.push(x); firstLinePos.push(y); textAlign = 'center'; baseLine = 'middle'; } } path = path.concat(firstLinePos); let minX = null, maxX = null; let minY = null, maxY = null; let parts = poi.text.split(multi); let textWidth = 0; if (wrapper) { // 漢字和數字的寬度是不同的,所以必須使用measureText來精確測量 let textWidth1 = wrapper.context.measureText(parts[0]).width; let textWidth2 = wrapper.context.measureText(parts[1] || '').width; textWidth = Math.max(textWidth1, textWidth2); } else { textWidth = Math.max(parts[0].length, parts[1] ? parts[1].length : 0) * charWidth; } if (textAlign === 'left') { minX = x - hOffset; maxX = path[0] + textWidth; // 只用第一行文字 } else if (textAlign === 'right') { minX = path[0] - textWidth; maxX = x + hOffset; } else { // center minX = x - Math.max(textWidth / 2, hOffset); maxX = x + Math.max(textWidth / 2, hOffset); } if (baseLine === 'top') { maxY = y + vOffset; minY = y - vOffset - labelStyle.fontSize * parts.length; } else if (baseLine === 'bottom') { maxY = y + vOffset + labelStyle.fontSize * parts.length; minY = y - vOffset; } else { // middle minY = Math.min(y - vOffset, path[1] - labelStyle.fontSize / 2); maxY = Math.max(y + vOffset, path[1] + labelStyle.fontSize * (parts.length + 0.5 - 1)); } return { min: { x: minX, y: minY }, max: { x: maxX, y: maxY } }; } |