Threejs開發(fā)3D地圖實(shí)踐總結(jié)
前段時(shí)間連續(xù)上了一個(gè)月班,加班加點(diǎn)完成了一個(gè)3D攻堅(jiān)項(xiàng)目。也算是由傳統(tǒng)web轉(zhuǎn)型到webgl圖形學(xué)開發(fā)中,坑不少,做了一下總結(jié)分享。
1、法向量問題
法線是垂直于我們想要照亮的物體表面的向量。法線代表表面的方向因此他們?yōu)楣庠春臀矬w的交互建模中具有決定性作用。每一個(gè)頂點(diǎn)都有一個(gè)關(guān)聯(lián)的法向量。
如果一個(gè)頂點(diǎn)被多個(gè)三角形共享,共享頂點(diǎn)的法向量等于共享頂點(diǎn)在不同的三角形中的法向量的和。N=N1+N2;

所以如果不做任何處理,直接將3維物體的點(diǎn)傳遞給BufferGeometry,那么由于法向量被合成,經(jīng)過片元著色器插值后,就會(huì)得到這個(gè)黑不溜秋的效果
我的處理方式使頂點(diǎn)的法向量保持唯一,那么就需要在共享頂點(diǎn)處,拷貝一份頂點(diǎn),并重新計(jì)算索引,是的每個(gè)被多個(gè)面共享的頂點(diǎn)都有多份,每一份有一個(gè)單獨(dú)的法向量,這樣就可以使得每個(gè)面都有一個(gè)相同的顏色
2、光源與面塊顏色
開發(fā)過程中設(shè)計(jì)給了一套配色,然而一旦有光源,面塊的最終顏色就會(huì)與光源混合,顏色自然與最終設(shè)計(jì)的顏色大相徑庭。下面是Lambert光照模型的混合算法。
而且產(chǎn)品的要求是頂面保持設(shè)計(jì)的顏色,側(cè)面需要加入光源變化效果,當(dāng)對(duì)地圖做操作時(shí),側(cè)面顏色需要根據(jù)視角發(fā)生變化。那么我的處理方式是將頂面與側(cè)面分別繪制(創(chuàng)建兩個(gè)Mesh),頂面使用MeshLambertMaterial的emssive屬性設(shè)置自發(fā)光顏色與設(shè)計(jì)顏色保持一致,也就不會(huì)有光照效果,側(cè)面綜合使用Emssive與color來應(yīng)用光源效果。
3、POI標(biāo)注
Three中創(chuàng)建始終朝向相機(jī)的POI可以使用Sprite類,同時(shí)可以將文字和圖片繪制在canvas上,將canvas作為紋理貼圖放到Sprite上。但這里的一個(gè)問題是canvas圖像將會(huì)失真,原因是沒有合理的設(shè)置sprite的scale,導(dǎo)致圖片被拉伸或縮放失真。
問題的解決思路是要保證在3d世界中的縮放尺寸,經(jīng)過一系列變換投影到相機(jī)屏幕后仍然與canvas在屏幕上的大小保持一致。這需要我們計(jì)算出屏幕像素與3d世界中的長度單位的比值,然后將sprite縮放到合適的3d長度。
4、點(diǎn)擊拾取問題
webgl中3D物體繪制到屏幕將經(jīng)過以下幾個(gè)階段
所以要在3D應(yīng)用做點(diǎn)擊拾取,首先要將屏幕坐標(biāo)系轉(zhuǎn)化成ndc坐標(biāo)系,這時(shí)候得到ndc的xy坐標(biāo),由于2d屏幕并沒有z值所以,屏幕點(diǎn)轉(zhuǎn)化成3d坐標(biāo)的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坐標(biāo)轉(zhuǎn)化成3D坐標(biāo):
- ndc = P * MV * Vec4
- Vec4 = MV-1 * P -1 * ndc
這個(gè)過程在Three中的Vector3類中已經(jīng)有實(shí)現(xiàn):
- unproject: function () {
- var matrix = new Matrix4();
- return function unproject( camera ) {
- matrix.multiplyMatrices( camera.matrixWorld, matrix.getInverse( camera.projectionMatrix ) );
- return this.applyMatrix4( matrix );
- };
- }(),
將得到的3d點(diǎn)與相機(jī)位置結(jié)合起來做一條射線,分別與場(chǎng)景中的物體進(jìn)行碰撞檢測(cè)。首先與物體的外包球進(jìn)行相交性檢測(cè),與球不相交的排除,與球相交的保存進(jìn)入下一步處理。將所有外包球與射線相交的物體按照距離相機(jī)遠(yuǎn)近進(jìn)行排序,然后將射線與組成物體的三角形做相交性檢測(cè)。求出相交物體。當(dāng)然這個(gè)過程也由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、性能優(yōu)化
隨著場(chǎng)景中的物體越來越多,繪制過程越來越耗時(shí),導(dǎo)致手機(jī)端幾乎無法使用。
在圖形學(xué)里面有個(gè)很重要的概念叫“one draw all”一次繪制,也就是說調(diào)用繪圖api的次數(shù)越少,性能越高。比如canvas中的fillRect、fillText等,webgl中的drawElements、drawArrays;所以這里的解決方案是對(duì)相同樣式的物體,把它們的側(cè)面和頂面統(tǒng)一放到一個(gè)BufferGeometry中。這樣可以大大降低繪圖api的調(diào)用次數(shù),極大的提升渲染性能。
這樣解決了渲染性能問題,然而帶來了另一個(gè)問題,現(xiàn)在是吧所有樣式相同的面放在一個(gè)BufferGeometry中(我們稱為樣式圖形),那么在面點(diǎn)擊時(shí)候就無法單獨(dú)判斷出到底是哪個(gè)物體(我們稱為物體圖形)被選中,也就無法對(duì)這個(gè)物體進(jìn)行高亮縮放處理。我的處理方式是,把所有的物體單獨(dú)生成物體圖形保存在內(nèi)存中,做面點(diǎn)擊的時(shí)候用這部分?jǐn)?shù)據(jù)來做相交性檢測(cè)。對(duì)于選中物體后的高亮縮放處理,首先把樣式面中相應(yīng)部分裁減掉,然后把選中的物體圖形加入到場(chǎng)景中,對(duì)它進(jìn)行縮放高亮處理。裁剪方法是,記錄每個(gè)物體在樣式圖形中的其實(shí)索引位置,在需要裁切時(shí)候?qū)⑦@部分索引制零。在需要恢復(fù)的地方在把這部分索引恢復(fù)成原狀。
6、面點(diǎn)擊移動(dòng)到屏幕中央
這部分也是遇到了不少坑,首先的想法是:
面中心點(diǎn)目前是在世界坐標(biāo)系內(nèi)的坐標(biāo),先用center.project(camera)得到歸一化設(shè)備坐標(biāo),在根據(jù)ndc得到屏幕坐標(biāo),而后根據(jù)面中心點(diǎn)屏幕坐標(biāo)與屏幕中心點(diǎn)坐標(biāo)做插值,得到偏移量,在根據(jù)OribitControls中的pan方法來更新相機(jī)位置。這種方式最終以失敗告終,因?yàn)橄鄼C(jī)可能做各種變換,所以屏幕坐標(biāo)的偏移與3d世界坐標(biāo)系中的位置關(guān)系并不是線性對(duì)應(yīng)的。
最終的想法是:
我們現(xiàn)在想將點(diǎn)擊面的中心點(diǎn)移到屏幕中心,屏幕中心的ndc坐標(biāo)永遠(yuǎn)都是(0,0)我們的觀察視線與近景面的焦點(diǎn)的ndc坐標(biāo)也是0,0;也就是說我們要將面中心點(diǎn)作為我們的觀察點(diǎn)(屏幕的中心永遠(yuǎn)都是相機(jī)的觀察視線),這里我們可以直接將面中心所謂視線的觀察點(diǎn),利用lookAt方法求取相機(jī)矩陣,但如果這樣簡單處理后的效果就會(huì)給人感覺相機(jī)的姿態(tài)變化了,也就是會(huì)感覺并不是平移過去的,所以我們要做的是保持相機(jī)當(dāng)前姿態(tài)將面中心作為相機(jī)觀察點(diǎn)。
回想平移時(shí)我們將屏幕移動(dòng)轉(zhuǎn)化為相機(jī)變化的過程是知道屏幕偏移求target,這里我們要做的就是知道target反推屏幕偏移的過程。首先根據(jù)當(dāng)前target與面中心求出相機(jī)的偏移向量,根據(jù)相機(jī)偏移向量求出在相機(jī)x軸和up軸的投影長度,根據(jù)投影長度就能返推出應(yīng)該在屏幕上的平移量。
- 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);// 相機(jī)x軸
- var cyv = new Vector3(0, 0, 0).setFromMatrixColumn(scope.object.matrix, 1);// 相機(jī)y軸
- // 相機(jī)軸都是單位向量
- var pxl = deltaVector.dot(cxv)/* / cxv.length()*/; // 向量在相機(jī)x軸的投影
- var pyl = deltaVector.dot(cyv)/* / cyv.length()*/; // 向量在相機(jī)y軸的投影
- // offset=dx * vector(cx) + dy * vector(cy.project(xoz).normalize)
- // offset由相機(jī)x軸方向向量+相機(jī)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切換的主要內(nèi)容就是當(dāng)相機(jī)的視線軸與場(chǎng)景的平面垂直時(shí),使用平行投影,這樣用戶只能看到頂面給人的感覺就是2D視圖。所以要根據(jù)透視的視錐體計(jì)算出平行投影的世景體。
因?yàn)橛脩魰?huì)在2D、3D場(chǎng)景下做很多操作,比如平移、縮放、旋轉(zhuǎn),要想無縫切換,這個(gè)關(guān)鍵在于將平行投影與視錐體相機(jī)的位置、lookAt方式保持一致;以及將他們放大縮小的關(guān)鍵點(diǎn):distance的比例與zoom來保持一致。
平行投影中,zoom越大代表六面體的首尾兩個(gè)面面積越小,放大越大。
8、3D中地理級(jí)別
地理級(jí)別實(shí)際是像素跟墨卡托坐標(biāo)系下米的對(duì)應(yīng)關(guān)系,這個(gè)有通用的標(biāo)準(zhǔn)以及計(jì)算公式:
- r=6378137
- resolution=2*PI*r/(2^zoom*256)
各個(gè)級(jí)別中像素與米的對(duì)應(yīng)關(guān)系如下:
- 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中的計(jì)算策略是,首先需要將3D世界中的坐標(biāo)與墨卡托單位的對(duì)應(yīng)關(guān)系搞清楚,如果已經(jīng)是以mi來做單位,那么就可以直接將相機(jī)的投影屏幕的高度與屏幕的像素?cái)?shù)目做比值,得出的結(jié)果跟上面的ranking做比較,選擇不用的級(jí)別數(shù)據(jù)以及比例尺。注意3D地圖中的比例尺并不是在所有屏幕上的所有位置與現(xiàn)實(shí)世界都滿足這個(gè)比例尺,只能說是相機(jī)中心點(diǎn)在屏幕位置處的像素是滿足這個(gè)關(guān)系的,因?yàn)槠叫型队坝薪筮h(yuǎn)小的效果。
9、poi碰撞
由于標(biāo)注是永遠(yuǎn)朝著相機(jī)的,所以標(biāo)注的碰撞就是把標(biāo)注點(diǎn)轉(zhuǎn)換到屏幕坐標(biāo)系用寬高來計(jì)算矩形相交問題。至于具體的碰撞算法,大家可以在網(wǎng)上找到,這里不展開。下面是計(jì)算poi矩形的代碼