Threejs 開發3D地圖實踐總結

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

一、法向量問題算法

  法線是垂直於咱們想要照亮的物體表面的向量。法線表明表面的方向所以他們爲光源和物體的交互建模中具備決定性做用。每個頂點都有一個關聯的法向量。
  若是一個頂點被多個三角形共享,共享頂點的法向量等於共享頂點在不一樣的三角形中的法向量的和。N=N1+N2;
  因此若是不作任何處理,直接將3維物體的點傳遞給BufferGeometry,那麼因爲法向量被合成,通過片元着色器插值後,就會獲得這個黑不溜秋的效果
  
  個人處理方式使頂點的法向量保持惟一,那麼就須要在共享頂點處,拷貝一份頂點,並從新計算索引,是的每一個被多個面共享的頂點都有多份,每一份有一個單獨的法向量,這樣就可使得每一個面都有一個相同的顏色
  
 
二、光源與面塊顏色
  開發過程當中設計給了一套配色,然而一旦有光源,面塊的最終顏色就會與光源混合,顏色天然與最終設計的顏色截然不同。下面是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

  

三、POI標註canvas

  Three中建立始終朝向相機的POI可使用Sprite類,同時能夠將文字和圖片繪製在canvas上,將canvas做爲紋理貼圖放到Sprite上。但這裏的一個問題是canvas圖像將會失真,緣由是沒有合理的設置sprite的scale,致使圖片被拉伸或縮放失真。api

  

  問題的解決思路是要保證在3d世界中的縮放尺寸,通過一系列變換投影到相機屏幕後仍然與canvas在屏幕上的大小保持一致。這須要咱們計算出屏幕像素與3d世界中的長度單位的比值,而後將sprite縮放到合適的3d長度。  性能優化

  

  
四、點擊拾取問題
  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作了封裝,使用起來很簡單:app

mouse.x = ndcPos.x;
      mouse.y = ndcPos.y;

      this.raycaster.setFromCamera(mouse, camera);

      var intersects = this.raycaster.intersectObjects(this._getIntersectMeshes(floor, zoom), true);

 

五、性能優化dom

  隨着場景中的物體愈來愈多,繪製過程愈來愈耗時,致使手機端幾乎沒法使用。ide

  在圖形學裏面有個很重要的概念叫「one draw all」一次繪製,也就是說調用繪圖api的次數越少,性能越高。好比canvas中的fillRect、fillText等,webgl中的drawElements、drawArrays;因此這裏的解決方案是對相一樣式的物體,把它們的側面和頂面統一放到一個BufferGeometry中。這樣能夠大大下降繪圖api的調用次數,極大的提高渲染性能。性能

  

  這樣解決了渲染性能問題,然而帶來了另外一個問題,如今是吧全部樣式相同的面放在一個BufferGeometry中(咱們稱爲樣式圖形),那麼在麪點擊時候就沒法單獨判斷出究竟是哪一個物體(咱們稱爲物體圖形)被選中,也就沒法對這個物體進行高亮縮放處理。個人處理方式是,把全部的物體單獨生成物體圖形保存在內存中,作麪點擊的時候用這部分數據來作相交性檢測。對於選中物體後的高亮縮放處理,首先把樣式面中相應部分裁減掉,而後把選中的物體圖形加入到場景中,對它進行縮放高亮處理。裁剪方法是,記錄每一個物體在樣式圖形中的其實索引位置,在須要裁切時候將這部分索引制零。在須要恢復的地方在把這部分索引恢復成原狀。測試

 

六、麪點擊移動到屏幕中央

  這部分也是遇到了很多坑,首先的想法是:

  面中心點目前是在世界座標系內的座標,先用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

 

七、2/3D切換

  23D切換的主要內容就是當相機的視線軸與場景的平面垂直時,使用平行投影,這樣用戶只能看到頂面給人的感受就是2D視圖。因此要根據透視的視錐體計算出平行投影的世景體。

  由於用戶會在2D、3D場景下作不少操做,好比平移、縮放、旋轉,要想無縫切換,這個關鍵在於將平行投影與視錐體相機的位置、lookAt方式保持一致;以及將他們放大縮小的關鍵點:distance的比例與zoom來保持一致。

  平行投影中,zoom越大表明六面體的首尾兩個面面積越小,放大越大。
 
 
八、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地圖中的比例尺並非在全部屏幕上的全部位置與現實世界都知足這個比例尺,只能說是相機中心點在屏幕位置處的像素是知足這個關係的,由於平行投影有近大遠小的效果。

 

九、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
相關文章
相關標籤/搜索