在數據可視化領域利用webgl來創建三維場景或VR已經越來越普遍,各種開發框架也應運而生。今天我們就通過最基本的threejs來完成第一人稱視角的場景巡檢功能。如果你是一位threejs的初學者或正打算入門,我強烈推薦你仔細閱讀本文並在我的代碼基礎之上繼續深入學習。因為它將是你能夠在網上找到的最好的免費中文教程,通過本文你可以學習到一些基本的三維理論,threejs的api接口以及你應該掌握的數學知識。當然要想完全掌握threejs可能還有很長的路需要走,但至少今天我將帶你入門並傳授一些獨特的學習技巧。
第一人稱視角的場景巡檢主要需要解決兩個問題,人物在場景中的移動和碰撞檢測。移動與碰撞功能是所有三維場景首先需要解決的基本問題。為了方便理解,首先需要構建一個簡單的三維場景並在遇到問題的時候向你演示如何解決它。
1 <!DOCTYPE html> 2 <html lang="en"> 3 <head> 4 <meta charset="UTF-8"> 5 <title>平移與碰撞</title> 6 <script src="js/three.js"></script> 7 <script src="js/jquery3.4.1.js"></script> 8 </head> 9 <body> 10 <canvas id="mainCanvas"></canvas> 11 </body> 12 <script> 13 let scene, camera, renderer, leftPress, cube; 14 init(); 15 helper(); 16 createBoxer(); 17 animate(); 18 19 function init() { 20 // 初始化場景 21 scene = new THREE.Scene(); 22 scene.background = new THREE.Color(0xffffff); 23 24 // 創建渲染器 25 renderer = new THREE.WebGLRenderer({ 26 canvas: document.getElementById("mainCanvas"), 27 antialias: true, // 抗鋸齒 28 alpha: true 29 }); 30 renderer.setSize(window.innerWidth, window.innerHeight); 31 32 33 // 創建透視相機 34 camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000); 35 camera.position.set(0, 40, 30); 36 camera.lookAt(0, 0, 0); 37 38 // 參數初始化 39 mouse = new THREE.Vector2(); 40 raycaster = new THREE.Raycaster(); 41 42 // 環境光 43 var ambientLight = new THREE.AmbientLight(0x606060); 44 scene.add(ambientLight); 45 // 平行光 46 var directionalLight = new THREE.DirectionalLight(0xBCD2EE); 47 directionalLight.position.set(1, 0.75, 0.5).normalize(); 48 scene.add(directionalLight); 49 } 50 51 function helper() { 52 var grid = new THREE.GridHelper(100, 20, 0xFF0000, 0x000000); 53 grid.material.opacity = 0.1; 54 grid.material.transparent = true; 55 scene.add(grid); 56 57 var axesHelper = new THREE.AxesHelper(30); 58 scene.add(axesHelper); 59 } 60 61 function animate() { 62 requestAnimationFrame(animate); 63 renderer.render(scene, camera); 64 } 65 66 function createBoxer() { 67 var geometry = new THREE.BoxGeometry(5, 5, 5); 68 var material = new THREE.MeshPhongMaterial({color: 0x00ff00}); 69 cube = new THREE.Mesh(geometry, material); 70 scene.add(cube); 71 } 72 73 $(window).mousemove(function (event) { 74 event.preventDefault(); 75 if (leftPress) { 76 cube.rotateOnAxis( 77 new THREE.Vector3(0, 1, 0), 78 event.originalEvent.movementX / 500 79 ); 80 cube.rotateOnAxis( 81 new THREE.Vector3(1, 0, 0), 82 event.originalEvent.movementY / 500 83 ); 84 } 85 }); 86 87 $(window).mousedown(function (event) { 88 event.preventDefault(); 89 leftPress = true; 90 91 }); 92 93 $(window).mouseup(function (event) { 94 event.preventDefault(); 95 leftPress = false; 96 }); 97 </script> 98 </html>
很多js的開發人員非常熟悉jquery,我引用它確實讓代碼顯得更加簡單。首先我在init()方法里初始化了一個場景。我知道在大部分示例中包括官方提供的demo里都是通過threejs動態的在document下創建一個<canvas/>節點。我強烈建議你不要這樣做,因為在很多單頁面應用中(例如:Vue和Angular)直接操作DOM都不被推薦。接下來我使用helper()方法創建了兩個輔助對象:一個模擬地面的網格和一個表示世界坐標系的AxesHelper。最后我利用createBoxer()方法在視角中央擺放了一個綠色的立方體以及綁定了三個鼠標動作用來控制立方地旋轉。如圖:
你可以嘗試將代碼復制到本地並在瀏覽器中運行,移動鼠標看看效果。接下來,為了讓方塊移動起來,我們需要添加一些鍵盤響應事件,以及給方塊的“正面”上色。
1 <!DOCTYPE html> 2 <html lang="en"> 3 <head> 4 <meta charset="UTF-8"> 5 <title>平移與碰撞</title> 6 <script src="js/three.js"></script> 7 <script src="js/jquery3.4.1.js"></script> 8 </head> 9 <body> 10 <canvas id="mainCanvas"></canvas> 11 </body> 12 <script> 13 let scene, camera, renderer, leftPress, cube; 14 let left, right, front, back; 15 init(); 16 helper(); 17 createBoxer(); 18 animate(); 19 20 function init() { 21 // 初始化場景 22 scene = new THREE.Scene(); 23 scene.background = new THREE.Color(0xffffff); 24 25 // 創建渲染器 26 renderer = new THREE.WebGLRenderer({ 27 canvas: document.getElementById("mainCanvas"), 28 antialias: true, // 抗鋸齒 29 alpha: true 30 }); 31 renderer.setSize(window.innerWidth, window.innerHeight); 32 33 34 // 創建透視相機 35 camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000); 36 camera.position.set(0, 40, 30); 37 camera.lookAt(0, 0, 0); 38 39 // 參數初始化 40 mouse = new THREE.Vector2(); 41 raycaster = new THREE.Raycaster(); 42 43 // 環境光 44 var ambientLight = new THREE.AmbientLight(0x606060); 45 scene.add(ambientLight); 46 // 平行光 47 var directionalLight = new THREE.DirectionalLight(0xBCD2EE); 48 directionalLight.position.set(1, 0.75, 0.5).normalize(); 49 scene.add(directionalLight); 50 } 51 52 function helper() { 53 var grid = new THREE.GridHelper(100, 20, 0xFF0000, 0x000000); 54 grid.material.opacity = 0.1; 55 grid.material.transparent = true; 56 scene.add(grid); 57 58 var axesHelper = new THREE.AxesHelper(30); 59 scene.add(axesHelper); 60 } 61 62 function animate() { 63 requestAnimationFrame(animate); 64 renderer.render(scene, camera); 65 if (front) { 66 cube.translateZ(-1) 67 } 68 if (back) { 69 cube.translateZ(1); 70 } 71 if (left) { 72 cube.translateX(-1); 73 } 74 if (right) { 75 cube.translateX(1); 76 } 77 } 78 79 function createBoxer() { 80 var geometry = new THREE.BoxGeometry(5, 5, 5); 81 var mats = []; 82 mats.push(new THREE.MeshPhongMaterial({color: 0x00ff00})); 83 mats.push(new THREE.MeshPhongMaterial({color: 0xff0000})); 84 cube = new THREE.Mesh(geometry, mats); 85 for (let j = 0; j < geometry.faces.length; j++) { 86 if (j === 8 || j === 9) { 87 geometry.faces[j].materialIndex = 1; 88 } else { 89 geometry.faces[j].materialIndex = 0; 90 } 91 } 92 scene.add(cube); 93 } 94 95 $(window).mousemove(function (event) { 96 event.preventDefault(); 97 if (leftPress) { 98 cube.rotateOnAxis( 99 new THREE.Vector3(0, 1, 0), 100 event.originalEvent.movementX / 500 101 ); 102 cube.rotateOnAxis( 103 new THREE.Vector3(1, 0, 0), 104 event.originalEvent.movementY / 500 105 ); 106 } 107 }); 108 109 $(window).mousedown(function (event) { 110 event.preventDefault(); 111 leftPress = true; 112 113 }); 114 115 $(window).mouseup(function (event) { 116 event.preventDefault(); 117 leftPress = false; 118 }); 119 120 $(window).keydown(function (event) { 121 switch (event.keyCode) { 122 case 65: // a 123 left = true; 124 break; 125 case 68: // d 126 right = true; 127 break; 128 case 83: // s 129 back = true; 130 break; 131 case 87: // w 132 front = true; 133 break; 134 } 135 }); 136 137 $(window).keyup(function (event) { 138 switch (event.keyCode) { 139 case 65: // a 140 left = false; 141 break; 142 case 68: // d 143 right = false; 144 break; 145 case 83: // s 146 back = false; 147 break; 148 case 87: // w 149 front = false; 150 break; 151 } 152 }); 153 </script> 154 </html>
我們添加了keydown()事件和keyup()事件用來捕獲鍵盤響應。我們還修改了createBoxer()方法,給朝向我們的那一面塗上紅色。你一定發現了BoxGeometry所代表的立方體雖然只有6個面,可是為了給“1個面”上色我們卻需要同時在“2個面”的材質上着色。這是因為在三維場景中,“面”的含義表示由空間中3個點所代表的區域,而一個矩形由兩個三角形拼接而成。完成以后的樣子如下:
隨意拖動幾下鼠標,我們可能會得到一個類似的狀態:
設想一下在第一人稱視角的游戲中,我們抬高視角觀察周圍后再降低視角,地平線是否依然處於水平狀態。換句話說,無論我們如何拖動鼠標,紅色的那面在朝向我們的時候都不應該傾斜。要解釋這個問題,我們首先需要搞清楚三維場景中的坐標系概念。在threejs的世界中存在兩套坐標體系:世界坐標系和自身坐標系。世界坐標系是整個場景的坐標系統,通過它可以定位場景中的物體。而自身坐標系就比較復雜,實際上一個物體的自身坐標系除了用來表示物體各個部分的相對關系以外主要用來表示物體的旋轉。想象一下月球的自轉和公轉,在地月坐標系中,月球圍繞地球公轉,同時也繞着自身的Y軸旋轉。在我們上面的場景中,立方體自身的坐標軸會隨着自身的旋轉而改變,當我們的鼠標自下而上滑動后,Y軸將不再垂直於地面。如果這時我們再橫向滑動鼠標讓立方體繞Y軸旋轉,自然整個面都會發生傾斜。如果你還不理解可以在自己的代碼中多嘗試幾次,理解世界坐標系和自身坐標系對於學習webgl尤其重要。很顯然,要模擬第一人稱的視角轉動我們需要讓視角上下移動的旋轉軸為自身坐標系的X軸,左右移動的旋轉軸固定為穿過自身中心的一條與世界坐標系Y軸保持平行的軸線。理解這個問題很不容易,可是解決它卻非常簡單。threejs為我們提供了方法,我們只需要修改mousemove()方法:
$(window).mousemove(function (event) { event.preventDefault(); if (leftPress) { cube.rotateOnWorldAxis( new THREE.Vector3(0, 1, 0), event.originalEvent.movementX / 500 ); cube.rotateOnAxis( new THREE.Vector3(1, 0, 0), event.originalEvent.movementY / 500 ); } });
有了控制視角的方式,接下來我們移動一下方塊。新的問題又出現了:盒子的運動方向也是沿着自身坐標系的。就和我們看着月亮行走並不會走到月亮上去的情形一樣,如果要模擬第一人稱視角的移動,視角的移動方向應該永遠和世界坐標系保持平行,那么我們是否可以通過世界坐標系來控制物體的移動呢:
1 function animate() { 2 requestAnimationFrame(animate); 3 renderer.render(scene, camera); 4 if (front) { 5 // cube.translateZ(-1) 6 cube.position.z -= 1; 7 } 8 if (back) { 9 // cube.translateZ(1); 10 cube.position.z += 1; 11 } 12 if (left) { 13 // cube.translateX(-1); 14 cube.position.x -= 1; 15 } 16 if (right) { 17 // cube.translateX(1); 18 cube.position.x += 1; 19 } 20 }
很顯然也不行,原因是我們應該讓物體的前進方向與物體面對的方向保持一致:
盡管這個需求顯得如此合理,可是threejs似乎並沒有提供有效的解決方案,就連官方示例中提供的基於第一人稱的移動也僅僅是通過固定物體Y軸數值的方法實現的。在射擊游戲中不能蹲下或爬上屋頂實在不能讓玩家接受。為了能夠在接下來的變換中分解問題和測試效果,我們在模型上添加兩個箭頭表示物體的前后方向。
1 let arrowFront, arrowBack; 2 3 function animate() { 4 requestAnimationFrame(animate); 5 renderer.render(scene, camera); 6 arrowFront.setDirection(cube.getWorldDirection(new THREE.Vector3()).normalize()); 7 arrowFront.position.copy(cube.position); 8 arrowBack.setDirection(cube.getWorldDirection(new THREE.Vector3()).negate().normalize()); 9 arrowBack.position.copy(cube.position); 10 if (front) { 11 // cube.translateZ(-1) 12 cube.position.z -= 1; 13 } 14 if (back) { 15 // cube.translateZ(1); 16 cube.position.z += 1; 17 } 18 if (left) { 19 // cube.translateX(-1); 20 cube.position.x -= 1; 21 } 22 if (right) { 23 // cube.translateX(1); 24 cube.position.x += 1; 25 } 26 } 27 28 function createBoxer() { 29 var geometry = new THREE.BoxGeometry(5, 5, 5); 30 var mats = []; 31 mats.push(new THREE.MeshPhongMaterial({color: 0x00ff00})); 32 mats.push(new THREE.MeshPhongMaterial({color: 0xff0000})); 33 cube = new THREE.Mesh(geometry, mats); 34 for (let j = 0; j < geometry.faces.length; j++) { 35 if (j === 8 || j === 9) { 36 geometry.faces[j].materialIndex = 1; 37 } else { 38 geometry.faces[j].materialIndex = 0; 39 } 40 } 41 scene.add(cube); 42 arrowFront = new THREE.ArrowHelper(cube.getWorldDirection(), cube.position, 15, 0xFF0000); 43 scene.add(arrowFront); 44 arrowBack = new THREE.ArrowHelper(cube.getWorldDirection().negate(), cube.position, 15, 0x00FF00); 45 scene.add(arrowBack); 46 }
修改后的效果如下:
有了箭頭的輔助,我們能夠以比較直觀的方式測試算法是否有效。如果你能夠認真讀到這里,可能已經迫不及待想繼續了,但是還請稍安勿躁。進入下個環節前,我們需要首先了解幾個重要的概念。
- 三維向量(Vector3):可以表征三維空間中的點或來自原點(0,0,0)的矢量。需要注意,Vector3既可以表示空間中的一個點又可以表示方向。因此為了避免歧義,我建議在作為矢量的時候通過normalize()方法對向量標准化。具體api文檔參考。
- 歐拉角(Euler):表示一個物體在其自身坐標系上的旋轉角度,歐拉角也是一個很常見的數學概念,優點是對於旋轉的表述相對直觀,不過我們在項目中並不常用。
- 四元數(Quaternion):四元數是一個相對高深的數學概念,幾何含義與歐拉角類似。都可以用來表征物體的旋轉方向,優點是運算效率更高。
- 四維矩陣(Matrix4):在threejs的世界中,任何一個對象都有它對應的四維矩陣。它集合了平移、旋轉、縮放等操作。有時我們可以通過它來完成兩個對象的動作同步。
- 叉積(.cross() ):向量叉積表示由兩個向量所確定的平面的法線方向。叉積的用途很多,例如在第一人稱的視角控制下,實現左右平移就可以通過當前視角方向z與垂直方向y做叉積運算獲得:z.cross(y)。
- 點積(.dot()):與向量叉積不同,向量點積為一個長度數據。vect_a.dot(vect_b)表示向量b在向量a上的投影長度,具體如何使用我們馬上就會看到
在理解了上面的概念以后,我們就可以實現沿視角方向平移的操作:我們知道,物體沿平面(XOZ)坐標系運動都可以分解為X方向上的運動分量和Z軸方向上的運動分量。首先獲取視角的方向,以三維向量表示。接着我們需要以這個向量和X軸方向上的一個三維向量做點積運算,從而得到一個投影長度。這個長度即代表物體沿視角方向移動的水平x軸方向上的運動分量。同理,我們在計算與Z軸方向上的點積,又可以獲得物體沿視角方向移動的z軸方向的運動分量。同時執行兩個方向上的運動分量完成平移操作。
接下來,我們先通過實驗觀察是否能夠獲得這兩個運動分量和投影長度。
1 <!DOCTYPE html> 2 <html lang="en"> 3 <head> 4 <meta charset="UTF-8"> 5 <title>平移與碰撞</title> 6 <script src="js/three.js"></script> 7 <script src="js/jquery3.4.1.js"></script> 8 </head> 9 <body> 10 <canvas id="mainCanvas"></canvas> 11 </body> 12 <script> 13 let scene, camera, renderer, leftPress, cube, arrowFront, arrowFrontX, arrowFrontZ; 14 let left, right, front, back; 15 init(); 16 // helper(); 17 createBoxer(); 18 animate(); 19 20 function init() { 21 // 初始化場景 22 scene = new THREE.Scene(); 23 scene.background = new THREE.Color(0xffffff); 24 25 // 創建渲染器 26 renderer = new THREE.WebGLRenderer({ 27 canvas: document.getElementById("mainCanvas"), 28 antialias: true, // 抗鋸齒 29 alpha: true 30 }); 31 renderer.setSize(window.innerWidth, window.innerHeight); 32 33 34 // 創建透視相機 35 camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000); 36 camera.position.set(0, 40, 30); 37 camera.lookAt(0, 0, 0); 38 39 // 參數初始化 40 mouse = new THREE.Vector2(); 41 raycaster = new THREE.Raycaster(); 42 43 // 環境光 44 var ambientLight = new THREE.AmbientLight(0x606060); 45 scene.add(ambientLight); 46 // 平行光 47 var directionalLight = new THREE.DirectionalLight(0xBCD2EE); 48 directionalLight.position.set(1, 0.75, 0.5).normalize(); 49 scene.add(directionalLight); 50 } 51 52 function helper() { 53 var grid = new THREE.GridHelper(100, 20, 0xFF0000, 0x000000); 54 grid.material.opacity = 0.1; 55 grid.material.transparent = true; 56 scene.add(grid); 57 58 var axesHelper = new THREE.AxesHelper(30); 59 scene.add(axesHelper); 60 } 61 62 function animate() { 63 requestAnimationFrame(animate); 64 renderer.render(scene, camera); 65 arrowFront.setDirection(cube.getWorldDirection(new THREE.Vector3()).normalize()); 66 arrowFront.position.copy(cube.position); 67 68 let vect = cube.getWorldDirection(new THREE.Vector3()); 69 arrowFrontX.setDirection(new THREE.Vector3(1, 0, 0)); 70 arrowFrontX.setLength(vect.dot(new THREE.Vector3(15, 0, 0))); 71 arrowFrontX.position.copy(cube.position); 72 73 arrowFrontZ.setDirection(new THREE.Vector3(0, 0, 1)); 74 arrowFrontZ.setLength(vect.dot(new THREE.Vector3(0, 0, 15))); 75 arrowFrontZ.position.copy(cube.position); 76 if (front) { 77 // cube.translateZ(-1) 78 cube.position.z -= 1; 79 } 80 if (back) { 81 // cube.translateZ(1); 82 cube.position.z += 1; 83 } 84 if (left) { 85 // cube.translateX(-1); 86 cube.position.x -= 1; 87 } 88 if (right) { 89 // cube.translateX(1); 90 cube.position.x += 1; 91 } 92 } 93 94 function createBoxer() { 95 var geometry = new THREE.BoxGeometry(5, 5, 5); 96 var mats = []; 97 mats.push(new THREE.MeshPhongMaterial({color: 0x00ff00})); 98 mats.push(new THREE.MeshPhongMaterial({color: 0xff0000})); 99 cube = new THREE.Mesh(geometry, mats); 100 for (let j = 0; j < geometry.faces.length; j++) { 101 if (j === 8 || j === 9) { 102 geometry.faces[j].materialIndex = 1; 103 } else { 104 geometry.faces[j].materialIndex = 0; 105 } 106 } 107 scene.add(cube); 108 arrowFront = new THREE.ArrowHelper(cube.getWorldDirection(), cube.position, 15, 0xFF0000); 109 scene.add(arrowFront); 110 111 let cubeDirec = cube.getWorldDirection(new THREE.Vector3()); 112 arrowFrontX = new THREE.ArrowHelper(cubeDirec.setY(0), cube.position, cubeDirec.dot(new THREE.Vector3(0, 0, 15)), 0x0000ff); 113 scene.add(arrowFrontX); 114 115 arrowFrontZ = new THREE.ArrowHelper(cubeDirec.setY(0), cube.position, cubeDirec.dot(new THREE.Vector3(15, 0, 0)), 0xB5B5B5) 116 scene.add(arrowFrontZ); 117 } 118 119 $(window).mousemove(function (event) { 120 event.preventDefault(); 121 if (leftPress) { 122 cube.rotateOnWorldAxis( 123 new THREE.Vector3(0, 1, 0), 124 event.originalEvent.movementX / 500 125 ); 126 cube.rotateOnAxis( 127 new THREE.Vector3(1, 0, 0), 128 event.originalEvent.movementY / 500 129 ); 130 } 131 }); 132 133 $(window).mousedown(function (event) { 134 event.preventDefault(); 135 leftPress = true; 136 137 }); 138 139 $(window).mouseup(function (event) { 140 event.preventDefault(); 141 leftPress = false; 142 }); 143 144 $(window).keydown(function (event) { 145 switch (event.keyCode) { 146 case 65: // a 147 left = true; 148 break; 149 case 68: // d 150 right = true; 151 break; 152 case 83: // s 153 back = true; 154 break; 155 case 87: // w 156 front = true; 157 break; 158 } 159 }); 160 161 $(window).keyup(function (event) { 162 switch (event.keyCode) { 163 case 65: // a 164 left = false; 165 break; 166 case 68: // d 167 right = false; 168 break; 169 case 83: // s 170 back = false; 171 break; 172 case 87: // w 173 front = false; 174 break; 175 } 176 }); 177 </script> 178 </html>
通過箭頭的輔助,我們很容易獲得以下圖形:
紅色箭頭表示物體的朝向,藍色表示物體沿x軸上的投影方向和長度。灰色表示沿z軸上的投影方向和長度。在確認方法可行以后,我們繼續實現平移操作。完整代碼如下,這個運算的方式很重要,讀者應該仔細比較兩段代碼的差別。
1 <!DOCTYPE html> 2 <html lang="en"> 3 <head> 4 <meta charset="UTF-8"> 5 <title>平移與碰撞</title> 6 <script src="js/three.js"></script> 7 <script src="js/jquery3.4.1.js"></script> 8 </head> 9 <body> 10 <canvas id="mainCanvas"></canvas> 11 </body> 12 <script> 13 let scene, camera, renderer, leftPress, cube, arrowFront, arrowFrontX, arrowFrontZ; 14 let left, right, front, back; 15 init(); 16 helper(); 17 createBoxer(); 18 animate(); 19 20 function init() { 21 // 初始化場景 22 scene = new THREE.Scene(); 23 scene.background = new THREE.Color(0xffffff); 24 25 // 創建渲染器 26 renderer = new THREE.WebGLRenderer({ 27 canvas: document.getElementById("mainCanvas"), 28 antialias: true, // 抗鋸齒 29 alpha: true 30 }); 31 renderer.setSize(window.innerWidth, window.innerHeight); 32 33 34 // 創建透視相機 35 camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000); 36 camera.position.set(0, 40, 30); 37 camera.lookAt(0, 0, 0); 38 39 // 參數初始化 40 mouse = new THREE.Vector2(); 41 raycaster = new THREE.Raycaster(); 42 43 // 環境光 44 var ambientLight = new THREE.AmbientLight(0x606060); 45 scene.add(ambientLight); 46 // 平行光 47 var directionalLight = new THREE.DirectionalLight(0xBCD2EE); 48 directionalLight.position.set(1, 0.75, 0.5).normalize(); 49 scene.add(directionalLight); 50 } 51 52 function helper() { 53 var grid = new THREE.GridHelper(100, 20, 0xFF0000, 0x000000); 54 grid.material.opacity = 0.1; 55 grid.material.transparent = true; 56 scene.add(grid); 57 58 var axesHelper = new THREE.AxesHelper(30); 59 scene.add(axesHelper); 60 } 61 62 function animate() { 63 requestAnimationFrame(animate); 64 renderer.render(scene, camera); 65 arrowFront.setDirection(cube.getWorldDirection(new THREE.Vector3()).normalize()); 66 arrowFront.position.copy(cube.position); 67 let vect = cube.getWorldDirection(new THREE.Vector3()); 68 if (front) { 69 cube.position.z += vect.dot(new THREE.Vector3(0, 0, 15)) * 0.01; 70 cube.position.x += vect.dot(new THREE.Vector3(15, 0, 0)) * 0.01; 71 } 72 } 73 74 function createBoxer() { 75 var geometry = new THREE.BoxGeometry(5, 5, 5); 76 var mats = []; 77 mats.push(new THREE.MeshPhongMaterial({color: 0x00ff00})); 78 mats.push(new THREE.MeshPhongMaterial({color: 0xff0000})); 79 cube = new THREE.Mesh(geometry, mats); 80 for (let j = 0; j < geometry.faces.length; j++) { 81 if (j === 8 || j === 9) { 82 geometry.faces[j].materialIndex = 1; 83 } else { 84 geometry.faces[j].materialIndex = 0; 85 } 86 } 87 scene.add(cube); 88 arrowFront = new THREE.ArrowHelper(cube.getWorldDirection(), cube.position, 15, 0xFF0000); 89 scene.add(arrowFront); 90 } 91 92 $(window).mousemove(function (event) { 93 event.preventDefault(); 94 if (leftPress) { 95 cube.rotateOnWorldAxis( 96 new THREE.Vector3(0, 1, 0), 97 event.originalEvent.movementX / 500 98 ); 99 cube.rotateOnAxis( 100 new THREE.Vector3(1, 0, 0), 101 event.originalEvent.movementY / 500 102 ); 103 } 104 }); 105 106 $(window).mousedown(function (event) { 107 event.preventDefault(); 108 leftPress = true; 109 110 }); 111 112 $(window).mouseup(function (event) { 113 event.preventDefault(); 114 leftPress = false; 115 }); 116 117 $(window).keydown(function (event) { 118 switch (event.keyCode) { 119 case 65: // a 120 left = true; 121 break; 122 case 68: // d 123 right = true; 124 break; 125 case 83: // s 126 back = true; 127 break; 128 case 87: // w 129 front = true; 130 break; 131 } 132 }); 133 134 $(window).keyup(function (event) { 135 switch (event.keyCode) { 136 case 65: // a 137 left = false; 138 break; 139 case 68: // d 140 right = false; 141 break; 142 case 83: // s 143 back = false; 144 break; 145 case 87: // w 146 front = false; 147 break; 148 } 149 }); 150 </script> 151 </html>
向后和左右平移的操作留給大家自己實現。有了以上基礎,如何控制Camera移動就很簡單了。幾乎就是將cube的操作替換成camera即可:
1 <!DOCTYPE html> 2 <html lang="en"> 3 <head> 4 <meta charset="UTF-8"> 5 <title>第一人稱視角移動</title> 6 <script src="js/three.js"></script> 7 <script src="js/jquery3.4.1.js"></script> 8 </head> 9 <body> 10 <canvas id="mainCanvas"></canvas> 11 </body> 12 <script> 13 let scene, camera, renderer, leftPress, cube, arrowFront, arrowFrontX, arrowFrontZ; 14 let left, right, front, back; 15 init(); 16 helper(); 17 animate(); 18 19 function init() { 20 // 初始化場景 21 scene = new THREE.Scene(); 22 scene.background = new THREE.Color(0xffffff); 23 24 // 創建渲染器 25 renderer = new THREE.WebGLRenderer({ 26 canvas: document.getElementById("mainCanvas"), 27 antialias: true, // 抗鋸齒 28 alpha: true 29 }); 30 renderer.setSize(window.innerWidth, window.innerHeight); 31 32 33 // 創建透視相機 34 camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000); 35 camera.position.set(0, 10, 30); 36 37 // 參數初始化 38 mouse = new THREE.Vector2(); 39 raycaster = new THREE.Raycaster(); 40 41 // 環境光 42 var ambientLight = new THREE.AmbientLight(0x606060); 43 scene.add(ambientLight); 44 // 平行光 45 var directionalLight = new THREE.DirectionalLight(0xBCD2EE); 46 directionalLight.position.set(1, 0.75, 0.5).normalize(); 47 scene.add(directionalLight); 48 } 49 50 function helper() { 51 var grid = new THREE.GridHelper(100, 20, 0xFF0000, 0x000000); 52 grid.material.opacity = 0.1; 53 grid.material.transparent = true; 54 scene.add(grid); 55 56 var axesHelper = new THREE.AxesHelper(30); 57 scene.add(axesHelper); 58 } 59 60 function animate() { 61 requestAnimationFrame(animate); 62 renderer.render(scene, camera); 63 let vect = camera.getWorldDirection(new THREE.Vector3()); 64 if (front) { 65 camera.position.z += vect.dot(new THREE.Vector3(0, 0, 15)) * 0.01; 66 camera.position.x += vect.dot(new THREE.Vector3(15, 0, 0)) * 0.01; 67 } 68 } 69 70 $(window).mousemove(function (event) { 71 event.preventDefault(); 72 if (leftPress) { 73 camera.rotateOnWorldAxis( 74 new THREE.Vector3(0, 1, 0), 75 event.originalEvent.movementX / 500 76 ); 77 camera.rotateOnAxis( 78 new THREE.Vector3(1, 0, 0), 79 event.originalEvent.movementY / 500 80 ); 81 } 82 }); 83 84 $(window).mousedown(function (event) { 85 event.preventDefault(); 86 leftPress = true; 87 88 }); 89 90 $(window).mouseup(function (event) { 91 event.preventDefault(); 92 leftPress = false; 93 }); 94 95 $(window).keydown(function (event) { 96 switch (event.keyCode) { 97 case 65: // a 98 left = true; 99 break; 100 case 68: // d 101 right = true; 102 break; 103 case 83: // s 104 back = true; 105 break; 106 case 87: // w 107 front = true; 108 break; 109 } 110 }); 111 112 $(window).keyup(function (event) { 113 switch (event.keyCode) { 114 case 65: // a 115 left = false; 116 break; 117 case 68: // d 118 right = false; 119 break; 120 case 83: // s 121 back = false; 122 break; 123 case 87: // w 124 front = false; 125 break; 126 } 127 }); 128 </script> 129 </html>
解決了平移操作以后,碰撞檢測其實就不那么復雜了。我們可以沿着攝像機的位置向上下前后左右六個方向做光線投射(Raycaster),每次移動首先檢測移動方向上的射線是否被阻擋,如果發生阻擋且距離小於安全距離,即停止該方向上的移動。后面的部分我打算放在下一篇博客中介紹,如果大家對這篇文章敢興趣或有什么建議歡迎給我留言或加群討論。