1. 程式人生 > >Threejs 開發3D地圖實踐總結

Threejs 開發3D地圖實踐總結

操作 moved ons pro 變化 delta otto api flat

  前段時間連續上了一個月班,加班加點完成了一個3D攻堅項目。也算是由傳統web轉型到webgl圖形學開發中,坑不少,做了一下總結分享。

1、法向量問題

  法線是垂直於我們想要照亮的物體表面的向量。法線代表表面的方向因此他們為光源和物體的交互建模中具有決定性作用。每一個頂點都有一個關聯的法向量。 技術分享   如果一個頂點被多個三角形共享,共享頂點的法向量等於共享頂點在不同的三角形中的法向量的和。N=N1+N2; 技術分享   所以如果不做任何處理,直接將3維物體的點傳遞給BufferGeometry,那麽由於法向量被合成,經過片元著色器插值後,就會得到這個黑不溜秋的效果   技術分享   我的處理方式使頂點的法向量保持唯一,那麽就需要在共享頂點處,拷貝一份頂點,並重新計算索引,是的每個被多個面共享的頂點都有多份,每一份有一個單獨的法向量,這樣就可以使得每個面都有一個相同的顏色   技術分享
2、光源與面塊顏色   開發過程中設計給了一套配色,然而一旦有光源,面塊的最終顏色就會與光源混合,顏色自然與最終設計的顏色大相徑庭。下面是Lambert光照模型的混合算法。 技術分享   而且產品的要求是頂面保持設計的顏色,側面需要加入光源變化效果,當對地圖做操作時,側面顏色需要根據視角發生變化。那麽我的處理方式是將頂面與側面分別繪制(創建兩個Mesh),頂面使用MeshLambertMaterial的emssive屬性設置自發光顏色與設計顏色保持一致,也就不會有光照效果,側面綜合使用Emssive與color來應用光源效果。   技術分享    技術分享
var material1 = new
__WEBPACK_IMPORTED_MODULE_0_three__["MeshLambertMaterial"]({ emissive: new __WEBPACK_IMPORTED_MODULE_0_three__["Color"](style.fillStyle[0], style.fillStyle[1], style.fillStyle[2]), side: __WEBPACK_IMPORTED_MODULE_0_three__["DoubleSide"], shading: __WEBPACK_IMPORTED_MODULE_0_three__[
"FlatShading"], vertexColors: __WEBPACK_IMPORTED_MODULE_0_three__["VertexColors"] }); var material2 = new __WEBPACK_IMPORTED_MODULE_0_three__["MeshLambertMaterial"]({ color: new __WEBPACK_IMPORTED_MODULE_0_three__["Color"](style.fillStyle[0] * 0.1, style.fillStyle[1] * 0.1, style.fillStyle[2] * 0.1), emissive: new __WEBPACK_IMPORTED_MODULE_0_three__["Color"](style.fillStyle[0] * 0.9, style.fillStyle[1] * 0.9, style.fillStyle[2] * 0.9), side: __WEBPACK_IMPORTED_MODULE_0_three__["DoubleSide"], shading: __WEBPACK_IMPORTED_MODULE_0_three__["FlatShading"], vertexColors: __WEBPACK_IMPORTED_MODULE_0_three__["VertexColors"] });
View Code

  

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.‘ );

    }
  }
View Code

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矩形的代碼

技術分享
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
    }
  };
}
View Code

  

Threejs 開發3D地圖實踐總結