• 仅有 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鱼眼效果图

  • 相关阅读:
    LOJ6284. 数列分块入门 8 题解
    LOJ6283. 数列分块入门 7 题解
    LOJ6281. 数列分块入门 5 题解
    LOJ6280. 数列分块入门 4 题解
    LOJ6279. 数列分块入门 3 题解
    LOJ6278. 数列分块入门 2 题解
    LOJ6277. 数列分块入门 1 题解
    洛谷P3402 可持久化并查集 题解
    P3919 【模板】可持久化线段树 1(可持久化数组)题解 主席树模板题
    计算机图形学:凹凸贴图、法线贴图、切线空间、TBN矩阵
  • 原文地址:https://www.cnblogs.com/nonoleaves/p/3771467.html
Copyright © 2020-2023  润新知