最近業務比較忙,但是我們追求3D世界的腳步不能停下來~某天在路上看到一輛輛飛馳而過的汽車,想到要不要弄一個賽車類的遊戲
沒有再用原生,而是使用了threejs,畢竟大點的3D專案,再用原生就是自己給自己找麻煩了……
本文從0到1講解了這個遊戲的開發過程,其中沒有專門的介紹webgl和threejs,沒有基礎的同學可以結合threejs文件一起看,或者先學習一下webgl的基礎知識~
遊戲地址如下:
https://vorshen.github.io/simpleCar/
操作如下:
w前進
a、d左右轉
space減速可漂移
目前遊戲的碰撞檢測沒有做完(後續會更新進行完善),只會進行汽車左邊與賽道中兩條邊進行碰撞檢測。具體哪裡下面會說~大家也可以通過親自試玩來找到哪兩條邊
下面我們就來從0到1去實現這個賽車遊戲~
注意:文中出現的程式碼片段是有作用域的!!為了配合講解上下文而刪減了其他的內容!完整程式碼地址如下:
https://github.com/vorshen/simpleCar
1、遊戲準備
首先我們要選擇做一款什麼遊戲,如果是公司級的遊戲專案,那開發基本是沒有選擇權的。自己做著練手那就可以按自己喜好來了。我之所以選擇賽車來舉例子:
首先是因為賽車遊戲比較簡單,沒有過多的素材要求。畢竟是個人開發,沒有專門的設計大大提供模型,模型得自己去找。
其次是賽車遊戲簡單閉環的成本低,有車,有跑道,能跑起來其實就是一款最簡單的遊戲了
所以最終就決定了做一款一切從簡的賽車遊戲,接下來我們要尋找素材
2、素材準備
在網上扒了很久,找到了一款不錯的汽車obj檔案,貼圖啥的都有,不過有的顏色還沒上,用blender進行補齊一下
汽車素材有了,接下來就是賽道的。賽道最早的想法是動態生成,類似之前那個迷宮遊戲一樣
正規的賽車遊戲肯定沒法動態生成,因為賽道都需要定製的,有很多細節的東西,比如貼圖風景之類的。
我們這個練手專案追求不了那麼酷炫,所以可以考慮一下動態生成。
動態生成的好處就是每次重新整理後玩都是一個新的地圖,可能新鮮度會高一些。
動態生成的也有兩種玩法,一種是用一塊板不停的去平鋪,板的頂點資訊
[-1,0,1, 1,0,1, 1,0,-1, -1,0,-1]
用俯檢視看起來就是下面這樣
但是這個有一個很不好的,就是彎道太粗糙了,每個彎道都是直角,不怎麼好看。就換一個方案
obj建兩個模型,分別是直道、轉彎,如圖
然後這兩個個模型不停的去平鋪
用2D看起來就像下面這樣
看起來這個是可行的,但是!真實實現之後發現還是不好!
首先賽道沒法回頭了,因為我們y軸是固定的,沒有上下坡的概念。一旦賽道回頭新的道路碰到已有的道路就會亂,變成岔路的感覺
其次針對隨機要做很多的控制,否則可能出現彎道過於頻繁,如圖
相容了一會,發現很是操蛋,所以決定還是自己建一個賽道模型,自己動手豐衣足食,如圖
再次安利下blender還是很好用的~
在這裡設計賽道的時候有一個彎道設計的太難了,不減速無法無碰撞過彎……相信試玩一圈肯定能找到是哪一個彎~
3、threejs
準備工作都弄完了,接下來就是擼程式碼啦
不知道之前原生webgl開發大家還記得不,很繁瑣對不對,這次我們用了threejs,可就方便很多了。不過還是要說一下,推薦先把原生webgl弄熟一些再去接觸threejs,否則可能會有很大的依賴性,而且對圖形學的一些基礎會不牢固。
我們第一步建立整個場景世界
1 2 3 4 5 6 7 8 9 10 11 12 |
var scene = new THREE.Scene(); var camera = new THREE.PerspectiveCamera(90, window.innerWidth/window.innerHeight, 0.1, 1000); camera.position.z = 0; camera.position.x = 0; var webGLRenderer = new THREE.WebGLRenderer(); webGLRenderer.setPixelRatio(window.devicePixelRatio); webGLRenderer.setSize(window.innerWidth, window.innerHeight); webGLRenderer.setClearColor(0x0077ec, 1); |
這些是使用threejs必須要有的,比我們自己原生去建立program,shader,再各種編譯繫結方便了很多
接下來我們要把模型給匯入進去。上次有寫過一個簡單的objLoader,這次我們用threejs自帶的。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
var mtlLoader = new THREE.MTLLoader(); mtlLoader.setPath('./assets/'); // 路徑 mtlLoader.load('car4.mtl', function(materials) { // 材質完成之後再匯入obj materials.preload(); var objLoader = new THREE.OBJLoader(); objLoader.setMaterials(materials); objLoader.setPath('./assets/'); objLoader.load('car4.obj', function(object) { car = object; car.children.forEach(function(item) { item.castShadow = true; }); car.position.z = -20; car.position.y = -5; params.scene.add(car); self.car = car; params.cb(); }, function() { console.log('progress'); }, function() { console.log('error'); }); }); |
首先載入mtl檔案,生成材質之後再載入obj檔案,非常的方便。注意這裡我們把賽車加入到場景之後要調整一下position.zy,地面在我們這個世界中y軸座標為-5
上一段程式碼可以看出攝像機開始的z座標為0,我們將賽車的z座標初始設定為-20
同理再匯入賽道檔案,此時我們訪問的話,會發現一片漆黑,如圖
這是為什麼呢?
神說要有光!
本身賽道和賽車是沒有顏色的,需要用材質+光出現顏色。原生webgl中製作光也比較麻煩,還需要寫shader,threejs又是很方便啦
我們只需要如下程式碼:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
var dirLight = new THREE.DirectionalLight(0xccbbaa, 0.5, 100); dirLight.position.set(-120, 500, -0); dirLight.castShadow = true; dirLight.shadow.mapSize.width = 1000; // default dirLight.shadow.mapSize.height = 1000; // default dirLight.shadow.camera.near = 2; dirLight.shadow.camera.far = 1000; dirLight.shadow.camera.left = -50; dirLight.shadow.camera.right = 50; dirLight.shadow.camera.top = 50; dirLight.shadow.camera.bottom = -50; scene.add(dirLight); var light = new THREE.AmbientLight( 0xccbbaa, 0.1 ); scene.add( light ); |
重新整理一下我們整個是世界亮堂堂起來了!(注意,這裡我們用的是環境光+平行光,後續我們會改成其他的光,原因也會給出),可是是不是少了一點什麼?對!還少了陰影
但是陰影我們放在下一節再說,因為這裡陰影的處理沒有光那麼簡單
拋開陰影,我們可以理解為一個靜態的世界已經完成了,有賽車有賽道
下面來做事件處理
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
document.body.addEventListener('keydown', function(e) { switch(e.keyCode) { case 87: // w car.run = true; break; case 65: // a car.rSpeed = 0.02; break; case 68: // d car.rSpeed = -0.02; break; case 32: // space car.brake(); break; } }); document.body.addEventListener('keyup', function(e) { switch(e.keyCode) { case 87: // w car.run = false; break; case 65: // a car.rSpeed = 0; break; case 68: // d car.rSpeed = 0; break; case 32: // space car.cancelBrake(); break; } }); |
我們沒有用鍵盤事件相關的庫,就幾個鍵,自己裸寫一下。程式碼應該還是很好懂的
按下w就意味著踩油門,car的run屬性置為true,tick中就要進行加速;同理a按下修改了rSpeed,在tick中car的rotation將會有所變化
程式碼如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
if(this.run) { this.speed += this.acceleration; if(this.speed > this.maxSpeed) { this.speed = this.maxSpeed; } } else { this.speed -= this.deceleration; if(this.speed < 0) { this.speed = 0; } } var speed = -this.speed; if(speed === 0) { return ; } var rotation = this.dirRotation += this.rSpeed; var speedX = Math.sin(rotation) * speed; var speedZ = Math.cos(rotation) * speed; this.car.rotation.y = rotation; this.car.position.z += speedZ; this.car.position.x += speed |
很方便,配合一些數學計算去修改car的rotation、position就ok了,比原生webgl自己實現各種變換矩陣方便多了,不過要知道threejs底層也還是通過matrix去變化的。
簡單總結一下這一節,我們用threejs去完成了整個世界的佈局,然後通過鍵盤事件讓汽車也可以動起來了,不過我們還缺少很多東西。
4、特性功能
這節主要說的是threejs無法實現或者threejs無法簡單實現的功能。先總結一下第三節結束之後,我們還欠缺的能力
a、攝像機跟隨
b、輪胎細節
c、陰影
d、碰撞檢測
e、漂移
下面一一道來
攝像機跟隨
剛才我們成功讓賽車移動了起來,但是我們的視角沒有動,車彷彿在漸漸遠離我們。視角是由攝像機控制的,之前我們建立了一個攝像機,現在我們要讓它跟隨著賽車運動。攝像機和賽車的關係如下面這兩幅圖
也就是說
攝像機的rotation和賽車的rotation是對應的,但是賽車無論轉向(rotation)還是移動(position)也都得去改變攝像機的position!這個對應關係要弄清楚
1 2 3 |
camera.rotation.y = rotation; camera.position.x = this.car.position.x + Math.sin(rotation) * 20; camera.position.z = this.car.position.z + Math.cos(rotation) * 20; |
在car的tick方法中,根據car本身的position和rotation,去算出camera的位置,20就是當賽車沒有旋轉時,攝像機和賽車的距離(第三節開頭有說過)。程式碼結合上面的圖一起理解好點
這樣就實現了攝像機的跟隨
輪胎細節
輪胎細節需要是為了體驗出偏航角時的真實性,不知道偏航角沒有關係,就理解為漂移時的真實性就好了,如下圖
其實普通轉向的時候,也是應該輪胎先行,車身再動,但我們這邊由於視角的問題就省略掉了
這裡核心就是車身方向和輪胎方向的不一致。不過這時候坑爹的就來了,threejs的rotation比較僵硬,它無法指定任意旋轉軸,要麼就是用rotation.xyz的方式旋轉座標軸,要麼就是rotateOnAxis的方式選擇一條通過原點的軸進行旋轉。所以我們只能對輪胎進行隨車旋轉,無法自轉。如圖
那麼我們想自轉,首先需要把輪胎模型給單獨抽出來,變成這樣,如圖
然後我們發現,自轉可以了,隨車旋轉沒了……那麼我們就要建立一個父級關係,隨車的旋轉是父級去做,自轉是輪胎本身去做的
程式碼如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 |
mtlLoader.setPath('./assets/'); mtlLoader.load(params.mtl, function(materials) { materials.preload(); var objLoader = new THREE.OBJLoader(); objLoader.setMaterials(materials); objLoader.setPath('./assets/'); objLoader.load(params.obj, function(object) { object.children.forEach(function(item) { item.castShadow = true; }); var wrapper = new THREE.Object3D(); // 父級 wrapper.position.set(0,-5,-20); // 設定輪胎本身在父級中的位置 wrapper.add(object); object.position.set(params.offsetX, 0, params.offsetZ); scene.add(wrapper); self.wheel = object; self.wrapper = wrapper; }, function() { console.log('progress'); }, function() { console.log('error'); }); }); …… this.frontLeftWheel.wrapper.rotation.y = this.realRotation; // 隨著車身一起的旋轉 this.frontRightWheel.wrapper.rotation.y = this.realRotation; this.frontLeftWheel.wheel.rotation.y = (this.dirRotation - this.realRotation) / 2; // 偏航角產生的自轉 this.frontRightWheel.wheel.rotation.y = (this.dirRotation - this.realRotation) / 2; |
陰影
之前我們把陰影給跳過了,說沒有光那麼簡單。其實陰影在threejs中實現,本身比webgl原生實現簡單了好幾個level。
看下threejs中陰影的實現,需要三步
1、光源計算陰影
2、物體計算陰影
3、物體承載陰影
這三步就可以讓你的場景中出現陰影
如下程式碼:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
dirLight.castShadow = true; // 光源計算陰影 dirLight.shadow.mapSize.width = 1000; dirLight.shadow.mapSize.height = 1000; dirLight.shadow.camera.near = 2; dirLight.shadow.camera.far = 1000; dirLight.shadow.camera.left = -50; dirLight.shadow.camera.right = 50; dirLight.shadow.camera.top = 50; dirLight.shadow.camera.bottom = -50; …… objLoader.load('car4.obj', function(object) { car = object; car.children.forEach(function(item) { item.castShadow = true; // 物體(賽車)計算陰影 }); …… objLoader.load('ground.obj', function(object) { object.children.forEach(function(item) { item.receiveShadow = true; // 物體(地面)承載陰影 }); |
但是!我們這裡是動態陰影,可以理解為整個場景都要在不斷的變化。這樣threejs中陰影就麻煩一些了,需要我們進行一些額外的處理。
首先我們知道我們的光是平行光,平行光可以看成太陽光,覆蓋整個場景的。但是陰影不行啊,陰影需要通過正射矩陣去算的!那麼問題來了,我們整個場景非常的大,正射矩陣如果想覆蓋整個場景,你的幀緩衝圖也非常的大,否則陰影會很不真實。其實無需考慮到這一步,因為幀緩衝圖壓根就不能那麼大,一定會卡成狗。
那怎麼辦?我們就得動態的去改變正射矩陣!
整個過程可以理解為這樣的
1 2 3 4 5 6 7 8 9 |
var tempX = this.car.position.x + speedX; var tempZ = this.car.position.z + speedZ; this.light.shadow.camera.left = (tempZ-50+20) >> 0; this.light.shadow.camera.right = (tempZ+50+20) >> 0; this.light.shadow.camera.top = (tempX+50) >> 0; this.light.shadow.camera.bottom = (tempX-50) >> 0; this.light.position.set(-120+tempX, 500, tempZ); this.light.shadow.camera.updateProjectionMatrix(); |
我們只考慮到了賽車在地面的陰影,所以正射矩陣只保證可以完整包含賽車就可以了。牆壁沒有去考慮,其實按完美來說牆壁也應該有陰影的,需要把正射矩陣拉大一點
但是!threejs中平行光就沒有鏡面反射的效果了,整個賽車曉得不夠生動,所以我就嘗試把平行光改成了點光源(路燈的感覺?),然後讓點光源也一直跟隨著賽車
這樣看起來整體就好了很多,之前說的更換光型別原因也就是在這~
碰撞檢測
不知道大家找到了哪幾條邊有碰撞檢測了沒,其實是這幾條邊~
紅色的這幾條邊和賽車的右邊有碰撞檢測,不過碰撞檢測做的很隨意,一旦碰上了就當作撞毀……直接速度置0重新出現了
確實是偷懶,因為碰撞檢測好搞,但這種賽車碰撞反饋在不接入物理引擎的情況下實在不好搞,要考慮很多,如果單純看成一個圓就會方便很多
所以我這次先給大家說碰撞檢測,如果想有很好的反饋……還是接入成熟的物理引擎比較好
賽車和賽道的碰撞檢測,我們先得把3D轉成2D去看,因為我們這邊也沒什麼障礙物上下坡啥的,簡單嘛
2D碰撞,我們可以去檢測賽車的左右邊和障礙物的邊
首先我們有了賽道的2D資料,再去動態獲得賽車的左右邊,拿去檢測
獲取左邊的程式碼
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
var tempA = -(this.car.rotation.y + 0.523); this.leftFront.x = Math.sin(tempA) * 8 + tempX; this.leftFront.y = Math.cos(tempA) * 8 + tempZ; tempA = -(this.car.rotation.y + 2.616); this.leftBack.x = Math.sin(tempA) * 8 + tempX; this.leftBack.y = Math.cos(tempA) * 8 + tempZ; …… Car.prototype.physical = function() { var i = 0; for(; i < outside.length; i += 4) { if(isLineSegmentIntr(this.leftFront, this.leftBack, { x: outside[i], y: outside[i+1] }, { x: outside[i+2], y: outside[i+3] })) { return i; } } return -1; }; |
這個和攝像頭的概念有點型別,不過數學計算上麻煩一些
線和線的碰撞檢測我們就用三角形面積法,最快的線與線碰撞檢測
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
function isLineSegmentIntr(a, b, c, d) { // console.log(a, b); var area_abc = (a.x - c.x) * (b.y - c.y) - (a.y - c.y) * (b.x - c.x); var area_abd = (a.x - d.x) * (b.y - d.y) - (a.y - d.y) * (b.x - d.x); if(area_abc * area_abd > 0) { return false; } var area_cda = (c.x - a.x) * (d.y - a.y) - (c.y - a.y) * (d.x - a.x); var area_cdb = area_cda + area_abc - area_abd ; if(area_cda * area_cdb > 0) { return false; } return true; } |
碰上之後呢?雖然我們沒有完美的反饋,但是基本的也應該有啊,我們將速度置0重新出現,總得把賽車方向重置正確對不對?否則玩家就一直在撞了……重置方向的用賽車本來的方向向量,去投影到碰撞邊,得出的向量就是重置的方向
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 |
function getBounceVector(obj, w) { var len = Math.sqrt(w.vx * w.vx + w.vy * w.vy); w.dx = w.vx / len; w.dy = w.vy / len; w.rx = -w.dy; w.ry = w.dx; w.lx = w.dy; w.ly = -w.dx; var projw = getProjectVector(obj, w.dx, w.dy); var projn; var left = isLeft(w.p0, w.p1, obj.p0); if(left) { projn = getProjectVector(obj, w.rx, w.ry); } else { projn = getProjectVector(obj, w.lx, w.ly); } projn.vx *= -0.5; projn.vy *= -0.5; return { vx: projw.vx + projn.vx, vy: projw.vy + projn.vy, }; } function getProjectVector(u, dx, dy) { var dp = u.vx * dx + u.vy * dy; return { vx: (dp * dx), vy: (dp * dy) }; } |
漂移
賽車沒有漂移,就好像就是開啟一款網路遊戲發現網線斷了一樣
我們這邊不去考慮漂移過彎和正常過彎到底哪個快,有興趣的同學可以查一查,還挺有意思的
先說明三點結論
1、漂移賽車遊戲的核心之一(帥),不做不行
2、漂移一大核心是出彎方向更佳,不需要扭動車頭(其他好處和壞處略,因為這個在視覺上看起來是最直觀的)
3、網上沒有現成很好用的漂移演算法(不考慮unity),所以需要我們來模擬漂移
模擬的話,我們就先要知道漂移的原理,還記得之前我們說的偏航角麼?偏航角就是漂移在視覺上的體驗
規範一點說偏航角就是賽車運動方向和車頭朝向方向不一致時,差異的角就叫做偏航角
所以我們的模擬漂移呢,需要做到兩步
1、產生偏航角,在視覺上讓玩家感受到漂移
2、出彎方向正確,在真實性上讓玩家感受到。總不至於玩家一個漂移後發現過彎更難受……
下面就針對這兩點進行模擬,其實知道了目的,還是很好模擬的
偏航角的產生,我們就要去維護兩個方向,一個是車身真正的旋轉方向realRotation,一個是賽車真正的運動方向dirRotation(攝像機跟隨的也是這個!)
在平時這兩個值都是一樣的,但是一旦使用者按下space,就要開始有所變化
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
var time = Date.now(); this.dirRotation += this.rSpeed; this.realRotation += this.rSpeed; var rotation = this.dirRotation; if(this.isBrake) { this.realRotation += this.rSpeed * (this.speed / 2); } this.car.rotation.y = this.realRotation; this.frontLeftWheel.wrapper.rotation.y = this.realRotation; this.frontRightWheel.wrapper.rotation.y = this.realRotation; this.frontLeftWheel.wheel.rotation.y = (this.dirRotation - this.realRotation) / 2; this.frontRightWheel.wheel.rotation.y = (this.dirRotation - this.realRotation) / 2; camera.rotation.y = this.dirRotation; |
此時偏航角已經產生
使用者鬆開space的時候,兩個方向要開始統一,此時切記一定是dirRotation朝著realRotation統一的,否則漂移出彎的意義就沒啦
結尾
時間關係,寫的不是那麼細節,不過核心的地方基本上也都寫了,如果有問題可以留言討論~
這個遊戲還是有非常多的不足與缺陷,後續我還會優化完善,感興趣的同學可以持續關注~
感謝您的閱讀~