- PVSM.RU - https://www.pvsm.ru -
Сегодня окунёмся в мир, который можно потрогать. В этой статье мы исследуем, как с нуля, быстро и без особо сложной математики написать движок для игры от первого лица. Для этого мы воспользуемся приёмом под названием «бросание лучей» (raycasting). Возможно, вы видели примеры такой техники в играх Daggerfall [1] и Duke Nukem 3D [2], а из более свежего – в статьях из «ludum dare» от Нотча Перссона [3]. Что ж, для Нотча это неплохо, но не для меня! Вот демка (управление стрелками и тачпадом) [источник] [4].
Бросание лучей может показаться жульничеством, но я – ленивый программист, и мне этот метод очень нравится. Достигается погружение в трёхмерную среду, минуя многие сложности «реального 3D», которые могли бы замедлять разработку. Например, время, затрачиваемое на бросание луча – это константа, поэтому можно загрузить огромный мир, и он просто заработает без каких-либо оптимизаций, как заработал бы и маленький. Уровни определяются в виде простых клеточных гридов, а не деревьев или полигональных сеток, так что в такой мир можно погрузиться, даже не имея опыта 3D-моделирования или математического диплома.
Словом, техника удивительно простая. Через пятнадцать минут вы уже будете фотографировать стены в офисе и проверять в корпоративных документах, «запрещено ли программировать имитацию перестрелок на рабочем месте».
Откуда будем бросать лучи? Это – важнейший аспект создания игрока. Нам понадобится всего три свойства, x, y и направление.
function Player(x, y, direction) {
this.x = x;
this.y = y;
this.direction = direction;
}
Сохраним нашу карту как простой двумерный массив. В этом массиве 0 соответствует нет стены, а 1 соответствует стена. Конечно, можно сделать и гораздо сложнее… например, отображать стены произвольной высоты или упаковать в массив несколько «слоёв» данных о стенах. Но в качестве пробы пера вариант 0-или-1 подойдёт отлично.
function Map(size) {
this.size = size;
this.wallGrid = new Uint8Array(size * size);
}
Вот в чём фокус: движок, использующий бросание лучей, не отрисовывает всю сцену сразу. Вместо этого сцена делится на самостоятельные столбцы, и они отображаются один за другим. Каждый столбец – это результат одного проброса луча от игрока под заданным углом. Если луч попадает в стену, то при этом измеряется расстояние до стены, а в столбце для этой стены рисуется прямоугольник. Высота прямоугольника зависит от того, какое расстояние прошёл луч – чем дальше стена, тем короче она кажется.
Чем больше лучей вы изобразите, тем более гладкая картинка получится.
Сначала найдём угол, под которым бросаем каждый луч. Угол зависит от трёх факторов: направления, в котором смотрит пользователь, фокусного расстояния камеры, а также от того, какой столбец мы сейчас отрисовываем.
var x = column / this.resolution - 0.5;
var angle = Math.atan2(x, this.focalLength);
var ray = map.cast(player, player.direction + angle, this.range);
Далее для каждого луча нужно проверить, есть ли у него на пути стены. Наша цель – получить массив, в котором перечислены все стены, оказывающиеся на пути луча, исходящего от игрока.
Считая игрока началом координат, найдём ближайшую горизонтальную (stepX) и вертикальную (stepY) линии сетки. Движемся к той из них, которая находится ближе, и проверяем, нет ли на этой линии стены (inspect). Затем повторяем процесс, пока не проследим луч на всю длину.
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, т. д.). Затем находим соответствующий y, умножив это значение на уклон линии (вверх / вниз).
var dx = run > 0 ? Math.floor(x + 1) - x : Math.ceil(x - 1) - x;
var dy = dx * (rise / run);
Заметили, что в этой части алгоритма самое классное? Нас вообще не волнует размер карты! Мы смотрим только конкретные точки в сетке, а количество таких точек в каждом кадре примерно равное. В нашем примере рассматривается карта 32 x 32, но карта размером 32 000 x 32 000 пробегалась бы так же быстро!
Выполнив трассировку луча, мы должны отрисовать все стены, которые нашли у него на пути.
var z = distance * Math.cos(angle);
var wallHeight = this.height * height / z;
Высоту каждой стены определяем, разделив её максимальную высоту на z. Соответственно, чем дальше от нас стена, тем короче мы её отрисуем.
Ох, чёрт, а откуда же взялся этот косинус? Если учитывать только дистанцию от игрока как таковую, то у нас получится эффект «рыбьего глаза». Почему? Представьте, что смотрите на стену. Левый и правый края стены будут от вас дальше, чем её центральная часть. Но вы же не хотите, чтобы прямые стены посередине вспучивались! Чтобы отображать стены плоскими, такими, как мы их реально видим, строим треугольник на основе каждого луча и находим длину перпендикуляра до стены, а для этого нужен косинус. Вот так:
Обещаю, самая сложная математика в статье уже пройдена!
Воспользуемся объектом Camera, чтобы отрисовать карту каждого кадра с точки зрения игрока. Именно этот объект отвечает за отображение каждой полосы при смахивании на экране слева направо или справа налево.
Прежде, чем отрисовать стены, отобразим скайбокс – это просто большая картинка на заднем плане, на которой есть звёзды и горизонт. Закончив с созданием стен, бросим на переднем плане оружие.
Camera.prototype.render = function(player, map) {
this.drawSky(player.direction, map.skybox, map.light);
this.drawColumns(player, map);
this.drawWeapon(player.weapon, player.paces);
};
Самые важные свойства камеры – это разрешение, фокусное расстояние и дальность.
При помощи объекта Controls будем слушать клавиши со стрелками (и события касания), а объект GameLoop будет вызывать requestAnimationFrame. Наш простой игровой цикл укладывается всего в три строки:
loop.start(function frame(seconds) {
map.update(seconds);
player.update(controls.states, map, seconds);
camera.render(player, map);
});
Дождь можно симулировать, расставив в случайных точках множество очень коротких стен.
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);
Вместо того, чтобы рисовать такие стены в полную ширину, мы рисуем каждую из них в один пиксель.
Фактически, здесь освещение – это затенение. Все стены рисуются в полную яркость, а потом стена накрывается чёрным прямоугольником с некоторым показателем непрозрачности. Непрозрачность зависит от расстояния, а также от ориентации стены (север/юг/восток/запад).
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 проводится независимо, так, чтобы пользователь мог скользить вплотную к стене:
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;
};
Без текстур стены могут выглядеть достаточно уныло. Как узнать, какой элемент стенных текстур применить к конкретному столбцу? На самом деле, это весьма просто: берём остаток от значения в точке пересечения.
step.offset = offset - Math.floor(offset);
var textureX = Math.floor(texture.width * step.offset);
Например, ширина пересечения со стеной в точке (10, 8.2) даёт остаток 0,2. Таким образом, точка пересечения на 20% удалена от левого края стены (8) и на 80% от правого края (9). Поэтому умножаем 0.2 * texture.width, чтобы найти координату x для текстурного изображения.
Погуляйте в жутких руинах [4].
Поскольку механизмы бросания лучей такие простые и быстрые, с ними можно оперативно протестировать сразу множество идей. Можно покататься на пещерном вездеходе, написать шутер от от первого лица или сделать песочницу для игры в стиле GTA. Мне вообще постоянно хочется запилить олдскульную MMORPG с огромным процедурно генерируемым миром.
Сделайте форк [5]!
Вот вам несколько заданий, чтобы потренироваться:
Автор: Александр
Источник [6]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/javascript/385614
Ссылки в тексте:
[1] Daggerfall: https://ru.wikipedia.org/wiki/The_Elder_Scrolls_II:_Daggerfall
[2] Duke Nukem 3D: https://ru.wikipedia.org/wiki/Duke_Nukem_3D
[3] Нотча Перссона: https://ru.wikipedia.org/wiki/%D0%9F%D0%B5%D1%80%D1%81%D1%81%D0%BE%D0%BD,_%D0%9C%D0%B0%D1%80%D0%BA%D1%83%D1%81
[4] демка (управление стрелками и тачпадом) [источник]: http://demos.playfuljs.com/raycaster
[5] Сделайте форк: https://github.com/hunterloftis/playfuljs-demos
[6] Источник: https://habr.com/ru/companies/timeweb/articles/744178/?utm_source=habrahabr&utm_medium=rss&utm_campaign=744178
Нажмите здесь для печати.