仅有 265 行的第一人称引擎

英文原文:A first-person engine in 265 lines

201463

今天,让我们进入到一个你可以触及的世界。本文中,我们将快速的、不含复杂数学知识的,使用一种称为光线投射算法的技术,从零开始的进行第一人称的探索。你可能在以前的游戏中,例如“匕首雨”、“毁灭公爵 3D”、乃至最近的“切口”佩尔森1的“Ludum Dare2应赛作品中可以看出此技术。如果它对“切口”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 表示墙。你也可以做的更复杂的多。。。例如,你可以将墙渲染成任意高度,或者你可以将几个带有“故事情节”的墙体数据放入到数组中,但对我们的第一次尝试,01 就可以工作的很好。

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的整数部分(123、等等)。之后,乘以线的斜率获得对应的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,之后迅速的衰减下来。

冲突检测

为了防止游戏角色走入墙中,我们只需检查它在地图中的下一步位置。我们分开检查xy,以便游戏角色可以沿着墙走:

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);

 

例如,在(108.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:死亡谷的天空背景

备注

1Markus "Notch" Persson

2Ludum Dare

3】Notch,绰号,有的文章翻译为“切口”

4鱼眼效果图

原文地址:https://www.cnblogs.com/nonoleaves/p/3771467.html