今天,让咱们进入一个能够伸手触摸的世界吧。在这篇文章里,咱们将从零开始快速完成一次第一人称探索。本文没有涉及复杂的数学计算,只用到了光线投射技术。你可能已经见识过这种技术了,好比《上古卷轴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)、第一人称射手、或者侠盗飞车式沙盒。靠!常数级的时间消耗真让我想作一个老式的大型多人在线角色扮演游戏,包含大量的、程序自动生成的世界。这里有一些带你起步的难题: