WebGL可以用來做3D效果的全景圖呈現,例如故宮的全景圖。但有時候我們不僅僅只是呈現全景圖,還需要增加互動。故宮里邊可以又分了很多區域,例如外朝中路、外朝西路、外朝東路等等。我們需要在3D圖上做一些標記表示某個小的區域。當點擊這個標記時,界面切換到對應標記區域的全景圖。下圖是實現此功能的一個小DEMO:
如何實現這樣的功能?通過本篇的介紹,我們可以了解到以上交互過程的代碼實現方式。這里我先提出幾個問題
1).如何獲取3D全景圖某個地址的3D坐標?
2).如何將獲取的地址的3D坐標轉換為屏幕上的2D坐標?
3).在旋轉3D全景圖時,如何讓3D坐標對應的2D屏幕坐標跟着移動?
4).如何確認一個標記點是在相機的可視區域?
搞清楚以上問題,全景圖的標記功能也就輕而易舉了。接下來我們就圍繞每個問題來實現功能。
如何獲取3D全景圖的某個地址的3D坐標?
一般獲取景區上某個地址的標記,都是通過手動獲取的。因為這些標記是無規律可尋的。所以我們就得考慮如何通過手動去獲取3D圖上的某個地址。人機交互時通過鼠標來操作,但鼠標是2D坐標,需要轉換到對應的3D坐標上。Three.js為我們提供了Raycaster對象,我們可以很輕松的獲取到一個2D點對應的3D坐標。先聲明幾個對象:
var raycasterCubeMesh; var raycaster = new THREE.Raycaster(); var mouseVector = new THREE.Vector3(); var tags = [];
這里需要在document上注冊mousemove事件,實時獲取鼠標對應的3D坐標。事件代碼如下:
function onMouseMove(event){ mouseVector.x = 2 * (event.clientX / window.innerHeight) - 1; mouseVector.y = - 2 * (event.clientY / window.innerHeight) + 1; raycaster.setFromCamera(mouseVector.clone(), camera); var intersects = raycaster.intersectObjects([cubeMesh]); if(raycasterCubeMesh){ scene.remove(raycasterCubeMesh); } activePoint = null; if(intersects.length > 0){ var points = []; points.push(new THREE.Vector3(0, 0, 0)); points.push(intersects[0].point); var mat = new THREE.MeshBasicMaterial({color: 0xff0000, transparent: true, opacity: 0.5}); var sphereGeometry = new THREE.SphereGeometry(100); raycasterCubeMesh = new THREE.Mesh(sphereGeometry, mat); raycasterCubeMesh.position.copy(intersects[0].point); scene.add(raycasterCubeMesh); activePoint = intersects[0].point; } }
代碼中的大部分我已經在“如何實現對象交互”有介紹。這里只介紹和當前功能相關代碼。intersects包含了鼠標當前位置下拾取到的3D對象集合。如果長度大於0,表示已經拾取到3D對象了。由於我們給intersectObjects函數只傳遞了cubeMesh對象(即全景圖),所以intersects的長度肯定為1。intersects[0].point表示鼠標投射到cubeMesh對象表面上的坐標。這個坐標正是我們需要的3D標記點。所以我把這個點存儲在activePoint。raycasterCubeMesh直接用交互點作為中心畫的一個球體,鼠標移動這個球體也就跟着移動。
鼠標移動時,能夠獲取到3D坐標了。如何確認這個坐標就是我們需要的?這里還得 給docuent注冊一個mousedown事件。通過右鍵點擊確認。注冊事件如下:
function onMouseDown(event){ if(event.buttons === 2 && activePoint){ var tagMesh = new THREE.Mesh(new THREE.SphereGeometry(1), new THREE.MeshBasicMaterial({color: 0xffff00})); tagMesh.position.copy(activePoint); tagObject.add(tagMesh); var tagElement = document.createElement("div"); tagElement.innerHTML = "<span>標記" + (tags.length + 1) + "</span>"; tagElement.style.background = "#00ff00"; tagElement.style.position = "absolute"; tagElement.addEventListener("click", function(evt){ alert(tagElement.innerText); }); tagMesh.updateTag = function(){ if(isOffScreen(tagMesh, camera)){ tagElement.style.display = "none"; }else{ tagElement.style.display = "block"; var position = toScreenPosition(tagMesh, camera); tagElement.style.left = position.x + "px"; tagElement.style.top = position.y + "px"; } } tagMesh.updateTag(); document.getElementById("WebGL-output").appendChild(tagElement); tags.push(tagMesh); } }
代碼第一行有if判斷,只有鼠標右鍵觸發,並且activePoint不為空,才執行下面的代碼。首先創建一個球體tagMesh並且設置坐標為activePoint,然后把它添加到tagObject對象中。tagObject是一個Object3D對象,用來存放所有的tagMesh,便於統一管理。
接着代碼創建了一個tagElement元素,設置樣式和內容。並且附加到WebGL容器中。tagMesh自定義了updateTag函數,里邊調用了兩個特別重要的函數:toScreenPosition和isOffScreen。這里先不忙介紹updateTag函數。接下來通過介紹這兩個函數來回答剩下的問題。
如何將獲取的地址的3D坐標轉換為屏幕上的2D坐標?
如果熟悉GIS的同學,應該知道什么叫做投影。我們將3D坐標映射到2D坐標的過程就叫做投影。toScreenPosition正是使用投影功能做的轉換。函數代碼如下:
function toScreenPosition(obj, camera){ var vector = new THREE.Vector3(); var widthHalf = 0.5 * renderer.context.canvas.width; var heightHalf = 0.5 * renderer.context.canvas.height; obj.updateMatrixWorld(); vector.setFromMatrixPosition(obj.matrixWorld); vector.project(camera); vector.x = (vector.x * widthHalf) + widthHalf; vector.y = -(vector.y * heightHalf) + heightHalf; return { x: vector.x, y: vector.y }; }
widthHalf和heightHalf分別表示canvas容器的寬度和高度的一半。接着更新obj對象的全局坐標。然后把vector的位置指向obj的全局坐標,之后調用viector.project(camera)將vector以相機為參考,轉換為2D坐標。但此時的2D坐標是笛卡爾坐標。原點在中間位置,需要轉換為屏幕坐標(原點在左上角)。最后返回的即是我們需要的2D坐標了。
在旋轉3D全景圖時,如何讓3D坐標對應的2D屏幕坐標跟着移動?
之前沒有介紹tagMesh的updateTag函數,這里我們再看下該函數:
tagMesh.updateTag = function(){ if(isOffScreen(tagMesh, camera)){ tagElement.style.display = "none"; }else{ tagElement.style.display = "block"; var position = toScreenPosition(tagMesh, camera); tagElement.style.left = position.x + "px"; tagElement.style.top = position.y + "px"; } }
這里只看else中代碼,設置元素的display為block,讓其可見。然后調用toScreenPosition(tagMesh, camera)獲取tagMesh 3D對象投影在屏幕上的坐標,所有我們直接設置給tagElement樣式的left和top。這只是第一步。如果全景圖旋轉了,tagElement和tagMesh位置又對應不上了。所有在每次渲染時還得調用該函數去執行更新2D坐標。
function render(){ controls.update(); tags.forEach(function(tagMesh){ tagMesh.updateTag(); }); renderer.render(scene, camera); requestAnimationFrame(render); }
上面代碼遍歷了所有的標記集合,每次渲染都更新一次。以上兩個步驟就實現了3D坐標和2D屏幕坐標的聯動。
如何只按照以上的介紹來實現功能,會發現一個問題。每添加一個標記,我們在旋轉全景圖時發現相機的前后都會顯示這個標記。這因為2D坐標沒有z方向,所以空間上會有兩個對稱點投影到相同的2D平面上。如何解決?看最后一個問題。
如何確認一個標記點是在相機的可視區域?
我們知道相機有可視區域,如果一個3D坐標在可視區域內,那么它投影到屏幕上的坐標需要顯示。而如果該3D坐標不在相機的可視區域,那么我們就不應該把該點投影到屏幕上。Three.js提供了Frustum對象解決這類問題。我們通過調用isOffScreen函數,判斷3D對象是否是離屏的。代碼如下:
function isOffScreen(obj, camera){ var frustum = new THREE.Frustum(); //Frustum用來確定相機的可視區域 var cameraViewProjectionMatrix = new THREE.Matrix4(); cameraViewProjectionMatrix.multiplyMatrices(camera.projectionMatrix, camera.matrixWorldInverse); //獲取相機的法線 frustum.setFromMatrix(cameraViewProjectionMatrix); //設置frustum沿着相機法線方向 return !frustum.intersectsObject(obj); }
首先創建Frustum對象,然后創建一個4 * 4矩陣對象。接下來的一行代碼把cameraViewProjectMatrix轉換為相機的法線矩陣。直接把它設置到frustum對象上。
接着調用frustum.intersectObject函數判斷obj是否在frustum的可視區域內。至於內部的實現邏輯,大家可查看Three.js的源代碼了解。
以上即是實現全景圖標記的核心代碼。至於全景圖如何創建,可以從我的github上下載源代碼查看。地址: