英文原文: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. 确定每个光线的角度
首先,我们要确定从哪个角度来投射每个光线。角度取决于三样:游戏角色面对的方向,相机的视野,当前绘制的列。
1 var angle = this.fov * (column / this.resolution - 0.5); 2 var ray = map.cast(player, player.direction + angle, this.range);
2. 在栅格中追踪每个光线
接下来,我们需要在每个光线的路径中检查墙体。我们的目标是,列出光线从游戏角色远离时穿过的每一堵墙所构成的数组。
从游戏角色出发,我们寻找最新的水平的(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 的地图也会运行的同样快!
3. 绘制列
一旦追踪完某条光线,我们需要画出路径中发现的任何墙体。
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。为了让你开始,这里有几个挑战:
- 身临其境。本例请求实现全屏的鼠标移动视角功能,并且带有下雨的背景,以及与闪电同步的雷声。
- 室内平面。将天空环境替换为对称梯度,也就是说,如果你有勇气,试着去渲染下屋顶和地板瓦片(可以这样想:它们仅仅是你已经绘制的墙体之间的空间)。
- 发光物体。我们已经有一个相对健壮的发光模型,那为什么不在这个时间中放入一些光,并根据它们来计算墙的亮度呢?80% 是环境光。
- 良好的触摸事件。我已经实现了几个基本的触摸控制事件,可以在手机、平板上分享来体验这个演示,依然拥有很大的改进空间。
- 相机效果。例如,缩放、模糊、醉酒模式,等等。通过光线投射法,则惊人的简单。在控制台中开始修改fov吧。
像往常一样,如果你做了一些很酷的东西,或者有类似的作品要分享,给我发邮件,或者推特我,我会在屋顶大声的喊出来。
讨论
请加入骇客新闻的这个讨论区。
- Comanche中的光线投射 -- 光线投射高度图的极佳示例
鸣谢
这个本应“两小时”完成的文章,最后写成了“三周”(额,我也翻译了好多个小时)。如果没有这些人的帮助,永远也不会写出来。
- Jim Snodgrass:编辑、反馈
- Jeremy Morrell:编辑、反馈
- Jeff Peterson:编辑、反馈
- Chris Gomez:武器、反馈
- Amanda Lenz:手提电脑包、支持
- Nicholas S:墙面纹理
- Dan Duriscoe:死亡谷的天空背景
备注
【2】Ludum Dare
【3】Notch,绰号,有的文章翻译为“切口”
【4】鱼眼效果图