英文原文:A first-person engine in 265 lines
2014年6月3日
今天,让我们进入到一个你可以触及的世界。本文中,我们将快速的、不含复杂数学知识的,使用一种称为光线投射算法的技术,从零开始的进行第一人称的探索。你可能在以前的游戏中,例如“匕首雨”、“毁灭公爵 3D”、乃至最近的“切口”佩尔森【1】的“Ludum Dare”【2】应赛作品中可以看出此技术。如果它对“切口”【3】而言足够好的话,那对我也没问题!【演示(箭头 / 触摸)】【源代码】
光线投射算法感觉就像欺骗一样,但作为一名懒惰的程序员,我喜欢它。你能获得3D 环境的沉迷体验,并且无须“真实 3D”的众多复杂性来延缓你的进度。例如,光线投射法以固定的时间运行,所以你能加载庞大的世界,它恰好无须优化,如同加载小世界一般的工作。水平面定义为简单的栅格,而不是多边形的网格。所以你能直接的投入其中,不需要3D建模的背景,不需要对数学的深入了解。
它是一种以简易打动你的技术。再过15分钟,你就会在办公室中拍摄墙体照片,并会检查人力资源档案,查找是否有“禁止将办公环境用于枪战环境”的条款。
我们要从哪里来投射光线?这就是游戏角色要做的。我们仅仅需要三个属性:x,y,direction。
1 function Player(x, y, direction) { 2 this.x = x; 3 this.y = y; 4 this.direction = direction; 5 }
我们要用一个简单的二维数组来存储地图。在这个数组中,0 表示没有墙,1 表示墙。你也可以做的更复杂的多。。。例如,你可以将墙渲染成任意高度,或者你可以将几个带有“故事情节”的墙体数据放入到数组中,但对我们的第一次尝试,0、1 就可以工作的很好。
1 function Map(size) { 2 this.size = size; 3 this.wallGrid = new Uint8Array(size * size); 4 }
这里有个技巧,光线投射引擎不会一次将整个场景画出。实际上,它将场景划分为多个独立的列,一一渲染它们。每个列表示来自位于特定角度的游戏角色的简单光线投射。如果光线碰到了墙上,它会测量到这个墙的距离,并在对应的列中画出一个矩形。矩形的高度由光线穿越的距离决定 -- 距墙越远,画的越短。
你画的光线越多,结果越平滑。
首先,我们要确定从哪个角度来投射每个光线。角度取决于三样:游戏角色面对的方向,相机的视野,当前绘制的列。
1 var angle = this.fov * (column / this.resolution - 0.5); 2 var ray = map.cast(player, player.direction + angle, this.range);
接下来,我们需要在每个光线的路径中检查墙体。我们的目标是,列出光线从游戏角色远离时穿过的每一堵墙所构成的数组。
从游戏角色出发,我们寻找最新的水平的(stepX)、垂直的(stepY)栅格线。我们移到两者中更近的那条线,检查墙体是否存在(inspect函数)。之后我们重复,直至我们追踪到每条光线的长度。
1 function ray(origin) { 2 var stepX = step(sin, cos, origin.x, origin.y); 3 var stepY = step(cos, sin, origin.y, origin.x, true); 4 var nextStep = stepX.length2 < stepY.length2 5 ? inspect(stepX, 1, 0, origin.distance, stepX.y) 6 : inspect(stepY, 0, 1, origin.distance, stepY.x); 7 8 if (nextStep.distance > range) return [origin]; 9 return [origin].concat(ray(nextStep)); 10 }
确定栅格交点的方法很明确:只需要查看x的整数部分(1、2、3、等等)。之后,乘以线的斜率获得对应的y(rise / run)。
1 var dx = run > 0 ? Math.floor(x + 1) - x : Math.ceil(x - 1) - x; 2 var dy = dx * (rise / run);
你注意到这段算法的出色之处了吗?我们不关心整个地图有多大!我们只在乎栅格上的特定点 -- 大概与每一帧上点的数目相同。我们的示例地图是 32 x 32,但 32000 x 32000 的地图也会运行的同样快!
一旦追踪完某条光线,我们需要画出路径中发现的任何墙体。
1 var z = distance * Math.cos(angle); 2 var wallHeight = this.height * height / z;
我们用墙的最大高度来除以z来确定它的高度。墙离的越远,我们将其绘的越短。
哦,糟糕,这个余弦函数哪来的?如果我们仅仅使用距离游戏角色的原始距离,我们只能获得鱼眼效果【4】。为什么呢?想象你面对着一扇墙,墙的左右两端比墙中间要离你远的多,而你也不期望直墙在中间膨胀出来!为了将直墙渲染成我们实际中所看到的样子,我们用每个光线构造一个三角,使用余弦来确定到墙的垂直距离。如下:
额,我保证,这是整件事上,最难的数学了。
让我们使用相机对象,从游戏角色的角度,画出地图的每一帧。当我们从屏幕最左横扫到最右时,由它负责呈现每个地带。
在它开始画墙前,我们先画个天空环境 -- 仅仅是带有星星和地平线的一大幅背景图。在墙画完后,我们要在前景放把武器。
1 Camera.prototype.render = function(player, map) { 2 this.drawSky(player.direction, map.skybox, map.light); 3 this.drawColumns(player, map); 4 this.drawWeapon(player.weapon, player.paces); 5 };
相机最重要的属性是解析度、视野(fov)、范围。
我们使用一个控制对象来监视方向键(与触摸事件),一个GameLoop对象来调用requestAnimationFrame。简单的游戏循环仅仅三行:
1 loop.start(function frame(seconds) { 2 map.update(seconds); 3 player.update(controls.states, map, seconds); 4 camera.render(player, map); 5 });
雨由一束非常短的、出现在随机地点的墙体来模拟。
1 var rainDrops = Math.pow(Math.random(), 3) * s; 2 var rain = (rainDrops > 0) && this.project(0.1, angle, step.distance); 3 4 ctx.fillStyle = '#ffffff'; 5 ctx.globalAlpha = 0.15; 6 while (--rainDrops > 0) ctx.fillRect(left, Math.random() * rain.top, 1, rain.height);
不是以整个宽度来绘制墙,而是以一个像素宽。
闪电实际上是着色出来的。所有的墙以完整的亮度绘制,之后用带有部分阻光的黑色矩形覆盖。不透明度由距离及墙的方向(东、西、南、北)决定。
1 ctx.fillStyle = '#000000'; 2 ctx.globalAlpha = Math.max((step.distance + step.shading) / this.lightRange - map.light, 0); 3 ctx.fillRect(left, wall.top, width, wall.height);
为了模拟闪电,map.light 随机的增长到 2,之后迅速的衰减下来。
为了防止游戏角色走入墙中,我们只需检查它在地图中的下一步位置。我们分开检查x和y,以便游戏角色可以沿着墙走:
1 Player.prototype.walk = function(distance, map) { 2 var dx = Math.cos(this.direction) * distance; 3 var dy = Math.sin(this.direction) * distance; 4 if (map.get(this.x + dx, this.y) <= 0) this.x += dx; 5 if (map.get(this.x, this.y + dy) <= 0) this.y += dy; 6 };
要是没有纹理的话,墙的绘制会变得相当烦人。我们如何知道墙纹理的哪个部分被应用到某个特定列呢?实际上很简单:我们选取交点的剩余部分。
1 step.offset = offset - Math.floor(offset); 2 var textureX = Math.floor(texture.width * step.offset);
例如,在(10,8.2)处与墙相交时,将有 0.2 的剩余。这意味着,当前的位置距离墙的左边沿(8 处) 20%,距离右边沿(9处) 80%。所以我们用 0.2 乘以 texture.width,来确定纹理图像的x坐标。
在这个恐怖的废墟中逛逛吧。
因为光线投射法快速简单,你可以迅速的实验多种构想。你可以制作地宫爬行游戏、第一人称射击游戏、侠盗猎车手风格的环境,喵的,恒定时间让我可以制作巨大的、具有程序自动生成世界能力的传统 MMORPG。为了让你开始,这里有几个挑战:
像往常一样,如果你做了一些很酷的东西,或者有类似的作品要分享,给我发邮件,或者推特我,我会在屋顶大声的喊出来。
请加入骇客新闻的这个讨论区。
鸣谢
这个本应“两小时”完成的文章,最后写成了“三周”(额,我也翻译了好多个小时)。如果没有这些人的帮助,永远也不会写出来。
【2】Ludum Dare
【3】Notch,绰号,有的文章翻译为“切口”
【4】鱼眼效果图