이 글에서는 Three.js를 사용하여 3D 지도를 개발하고 구현하는 실제 과정을 주로 소개하며, three.js를 사용하는 모든 사람이 배울 수 있는 확실한 참고 학습 가치가 있습니다. 아래에서 따라가면 됩니다. 그것이 모두에게 도움이 되기를 바랍니다.
머리말
이 글에서는 Three.js의 개발 기초와 기본 원리, 3D 파노라마 구현 방법을 주로 소개합니다. 웹에서 3D 파노라마 효과를 얻으려면 파노라마 이미지와 WebGL 외에도 많은 세부 사항을 처리해야 합니다. 제가 아는 한 현재 해외 3D 파노라마 서비스는 KrPano가 가장 좋고, 국내 3D 파노라마 서비스에서도 krpano 툴을 활용하고 있는 경우가 많습니다.
저는 얼마 전 한 달 연속 일했고 3D 프로젝트를 완료하기 위해 야근을 했습니다. 전통적인 웹에서 webgl 그래픽 개발로의 전환이라고 볼 수 있는데, 함정이 많아서 요약해서 공유했습니다.
Three.js
WebGL 개발의 복잡성을 단순화하고 시작의 어려움을 줄이기 위한 목적으로 mrdoob)은 WebGL 표준을 기반으로 하는 경량 JS 3D 라이브러리인 Three.js를 캡슐화했습니다.
제가 생각하는 Three.js의 특징은 다음과 같습니다.
3D 개발에 필요한 모든 기능을 갖추고 있습니다. 기본적으로 WebGL을 사용하여 얻을 수 있는 효과를 Three.js를 사용하면 더 쉽게 얻을 수 있습니다.
사용하기 쉬운 아키텍처 디자인은 비교적 명확하고 합리적이며 이해하기 쉽고 확장성이 좋으며 개발 효율성이 WebGL보다 높습니다내 처리 방법은 정점의 법선 벡터를 고유하게 만들기 때문에 공유 정점에서 정점을 복사하고 인덱스를 다시 계산해야 합니다. 예, 여러 면이 공유하는 각 정점에는 여러 부분이 있습니다. 부분은 별도의 법선 벡터를 가지므로 각 표면이 동일한 색상을 갖습니다 2. 광원 및 표면 블록 색상
컬러 매칭 세트는 개발 과정에서 설계되었지만 일단 광원이 있으면 패널의 최종 색상은 광원과 혼합되며 색상은 자연스럽게 최종 디자인의 색상과 매우 다릅니다. 다음은 Lambert 조명 모델의 하이브리드 알고리즘이다.
그리고 제품의 요구사항은 윗면이 디자인 색상을 유지하고 측면에 광원 변경 효과를 추가해야 한다는 것입니다. 지도 작동 시 측면 색상이 그에 따라 변경되어야 합니다. 시야각. 그런 다음 이를 처리하는 방법은 상단 표면과 측면을 별도로 그리는 것입니다(두 개의 Mesh 생성). MeshLambertMaterial의 emssive 속성을 사용하여 상단 표면의 자체 조명 색상을 디자인 색상과 일치하도록 설정합니다. 조명 효과는 없습니다. Emssive와 측면 색상을 종합적으로 사용하여 조명 효과를 적용합니다.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"] });
3개는 Sprite 클래스를 사용하여 항상 카메라를 향하는 POI를 생성하는 동시에 캔버스에 텍스트와 그림을 그릴 수 있습니다. 스프라이트를 텍스처 맵으로 사용합니다. 하지만 여기서 한 가지 문제는 캔버스 이미지가 왜곡된다는 것입니다. 그 이유는 스프라이트의 크기가 적절하게 설정되지 않아 이미지가 늘어나거나 크기가 조정되고 왜곡되기 때문입니다.
문제에 대한 해결책은 일련의 변환 및 카메라 화면에 대한 투영 후에 3D 세계의 확대/축소 크기가 화면의 캔버스 크기와 일관되게 유지되도록 하는 것입니다. 이를 위해서는 3D 세계의 길이 단위에 대한 화면 픽셀의 비율을 계산한 다음 적절한 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矩形的代码
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 } }; }
大家学会了吗?赶紧动尝试一下吧。
相关推荐:
위 내용은 Three.js는 3D 지도 인스턴스 공유를 구현합니다.의 상세 내용입니다. 자세한 내용은 PHP 중국어 웹사이트의 기타 관련 기사를 참조하세요!