今天,讓咱們進入一個能夠伸手觸摸的世界吧。在這篇文章裏,咱們將從零開始快速完成一次第一人稱探索。本文沒有涉及複雜的數學計算,只用到了光線投射技術。你可能已經見識過這種技術了,好比《上古卷軸5 : 天際》、《毀滅公爵3D》。程序員
用了光線投射就像開掛同樣,做爲一名懶得出油的程序員,我表示很是喜歡。你能夠舒暢地浸入到3D環境中而不受「真3D」複雜性的束縛。舉例來講,光線投射算法消耗線性時間,因此不用優化也能夠加載一個巨大的世界,它執行的速度跟小型世界同樣快。水平面被定義成簡單的網格而不是多邊形網面樹,因此即便沒有 3D 建模基礎或數學博士學位也能夠直接投入進去學習。算法
利用這些技巧很容易就能夠作一些讓人嗨爆的事情。15分鐘以後,你會處處拍下你辦公室的牆壁,而後檢查你的 HR 文檔看有沒有規則禁止「工做場所槍戰建模」。數組
咱們從何處投射光線?這就是玩家對象(Player)的做用,只須要三個屬性 x,y,direction。dom
JavaScript function Player(x, y, direction) { this.x = x; this.y = y; this.direction = direction; } function Player(x, y, direction) { this.x = x; this.y = y; this.direction = direction; }
咱們將地圖存做簡單的二維數組。數組中,0表明沒牆,1表明有牆。你還能夠作得更復雜些,好比給牆設任意高度,或者將多個牆數據的「樓層(stories)」打包進數組。但做爲咱們的第一次嘗試,用0-1就足夠了。oop
JavaScript function Map(size) { this.size = size; this.wallGrid = new Uint8Array(size * size); } function Map(size) { this.size = size; this.wallGrid = new Uint8Array(size * size); }
這裏就是竅門:光線投射引擎不會一次性繪製出整個場景。相反,它把場景分紅獨立的列而後一條一條地渲染。每一列都表明從玩家特定角度投射出的一條光線。若是光線碰到牆壁,引擎會計算玩家到牆的距離而後在該列中畫出一個矩形。矩形的高度取決於光線的長度——越遠則越短。學習
繪畫的光線越多,顯示效果就會越平滑。優化
咱們首先找出每條光線投射的角度。角度取決於三點:玩家面向的方向,攝像機的視野,還有正在繪畫的列。this
JavaScript var angle = this.fov * (column / this.resolution - 0.5); var ray = map.cast(player, player.direction + angle, this.range); var angle = this.fov * (column / this.resolution - 0.5); var ray = map.cast(player, player.direction + angle, this.range);
接下來,咱們要檢查每條光線通過的牆。這裏的目標是最終得出一個數組,列出了光線離開玩家後通過的每面牆。spa
從玩家開始,咱們找出最接近的橫向(stepX)和縱向(stepY)網格座標線。移到最近的地方而後檢查是否有牆(inspect)。一直重複檢查直到跟蹤完每條線的全部長度。prototype
JavaScript function ray(origin) { var stepX = step(sin, cos, origin.x, origin.y); var stepY = step(cos, sin, origin.y, origin.x, true); var nextStep = stepX.length2 < stepY.length2 ? inspect(stepX, 1, 0, origin.distance, stepX.y) : inspect(stepY, 0, 1, origin.distance, stepY.x); if (nextStep.distance > range) return [origin]; return [origin].concat(ray(nextStep)); } function ray(origin) { var stepX = step(sin, cos, origin.x, origin.y); var stepY = step(cos, sin, origin.y, origin.x, true); var nextStep = stepX.length2 < stepY.length2 ? inspect(stepX, 1, 0, origin.distance, stepX.y) : inspect(stepY, 0, 1, origin.distance, stepY.x); if (nextStep.distance > range) return [origin]; return [origin].concat(ray(nextStep)); }
尋找網格交點很簡單:只須要對 x 向下取整(1,2,3…),而後乘以光線的斜率(rise/run)得出 y。
JavaScript var dx = run > 0 ? Math.floor(x + 1) - x : Math.ceil(x - 1) - x; var dy = dx * (rise / run); var dx = run > 0 ? Math.floor(x + 1) - x : Math.ceil(x - 1) - x; var dy = dx * (rise / run);
如今看出了這個算法的亮點沒有?咱們不用關心地圖有多大!只須要關注網格上特定的點——與每幀的點數大體相同。樣例中的地圖是32×32,而32,000×32,000的地圖同樣跑得這麼快!
跟蹤完一條光線後,咱們就要畫出它在路徑上通過的全部牆。
JavaScript var z = distance * Math.cos(angle); var wallHeight = this.height * height / z; var z = distance * Math.cos(angle); var wallHeight = this.height * height / z;
咱們經過牆高度的最大除以 z 來以爲它的高度。越遠的牆,就畫得越短。
額,這裏用 cos 是怎麼回事?若是直接使用原來的距離,就會產生一種超廣角的效果(魚眼鏡頭)。爲何?想象你正面向一面牆,牆的左右邊緣離你的距離比牆中心要遠。因而本來直的牆中心就會膨脹起來了!爲了以咱們真實所見的效果去渲染牆面,咱們經過投射的每條光線一塊兒構建了一個三角形,經過 cos 算出垂直距離。如圖:
我向你保證,這裏已是本文最難的數學啦。
咱們用攝像頭對象 Camera 從玩家視角畫出地圖的每一幀。當咱們從左往右掃過屏幕時它會負責渲染每一列。
在繪製牆壁以前,咱們先渲染一個天空盒(skybox)——就是一張大的背景圖,有星星和地平線,畫完牆後咱們還會在前景放個武器。
JavaScript Camera.prototype.render = function(player, map) { this.drawSky(player.direction, map.skybox, map.light); this.drawColumns(player, map); this.drawWeapon(player.weapon, player.paces); }; Camera.prototype.render = function(player, map) { this.drawSky(player.direction, map.skybox, map.light); this.drawColumns(player, map); this.drawWeapon(player.weapon, player.paces); };
攝像機最重要的屬性是分辨率(resolution)、視野(fov)和射程(range)。
使用控制對象 Controls 監聽方向鍵(和觸摸事件)。使用遊戲循環對象 GameLoop 調用 requestAnimationFrame 請求渲染幀。 這裏的 gameloop 只有三行
JavaScript oop.start(function frame(seconds) { map.update(seconds); player.update(controls.states, map, seconds); camera.render(player, map); }); oop.start(function frame(seconds) { map.update(seconds); player.update(controls.states, map, seconds); camera.render(player, map); });
雨滴是用大量隨機放置的短牆模擬的。
JavaScript var rainDrops = Math.pow(Math.random(), 3) * s; var rain = (rainDrops > 0) && this.project(0.1, angle, step.distance); ctx.fillStyle = '#ffffff'; ctx.globalAlpha = 0.15; while (--rainDrops > 0) ctx.fillRect(left, Math.random() * rain.top, 1, rain.height); var rainDrops = Math.pow(Math.random(), 3) * s; var rain = (rainDrops > 0) && this.project(0.1, angle, step.distance); ctx.fillStyle = '#ffffff'; ctx.globalAlpha = 0.15; while (--rainDrops > 0) ctx.fillRect(left, Math.random() * rain.top, 1, rain.height);
這裏沒有畫出牆徹底的寬度,而是畫了一個像素點的寬度。
照明其實就是明暗處理。全部的牆都是以徹底亮度畫出來,而後覆蓋一個帶有必定不透明度的黑色矩形。不透明度決定於距離與牆的方向(N/S/E/W)。
JavaScript ctx.fillStyle = '#000000'; ctx.globalAlpha = Math.max((step.distance + step.shading) / this.lightRange - map.light, 0); ctx.fillRect(left, wall.top, width, wall.height); ctx.fillStyle = '#000000'; ctx.globalAlpha = Math.max((step.distance + step.shading) / this.lightRange - map.light, 0); ctx.fillRect(left, wall.top, width, wall.height);
要模擬閃電,map.light 隨機達到2而後再快速地淡出。
要防止玩家穿牆,咱們只要用他要到的位置跟地圖比較。分開檢查 x 和 y 玩家就能夠靠着牆滑行。
JavaScript Player.prototype.walk = function(distance, map) { var dx = Math.cos(this.direction) * distance; var dy = Math.sin(this.direction) * distance; if (map.get(this.x + dx, this.y) <= 0) this.x += dx; if (map.get(this.x, this.y + dy) <= 0) this.y += dy; }; Player.prototype.walk = function(distance, map) { var dx = Math.cos(this.direction) * distance; var dy = Math.sin(this.direction) * distance; if (map.get(this.x + dx, this.y) <= 0) this.x += dx; if (map.get(this.x, this.y + dy) <= 0) this.y += dy; };
沒有貼圖(texture)的牆面看起來會比較無趣。但咱們怎麼把貼圖的某個部分對應到特定的列上?這其實很簡單:取交叉點座標的小數部分。
JavaScript step.offset = offset - Math.floor(offset); var textureX = Math.floor(texture.width * step.offset); step.offset = offset - Math.floor(offset); var textureX = Math.floor(texture.width * step.offset);
舉例來講,一面牆上的交點爲(10,8.2),因而取小數部分0.2。這意味着交點離牆左邊緣20%遠,離牆右邊緣80%遠。因此咱們用 0.2 * texture.width 得出貼圖的 x 座標。
試一試
由於光線投射器是如此地快速、簡單,你能夠快速地實現許多想法。你能夠作個地牢探索者(Dungeon Crawler)、第一人稱射手、或者俠盜飛車式沙盒。靠!常數級的時間消耗真讓我想作一個老式的大型多人在線角色扮演遊戲,包含大量的、程序自動生成的世界。這裏有一些帶你起步的難題: