Создаем игровой движок с видом от первого лица за 265 строк кода на JavaScript
В этой статье мы создадим небольшой игровой движок с видом от первого лица без сложной математики и техник 3D-визуализации, используя метод рейкастинга (трассировки, или «бросания», лучей).
Рейкастинг — один из методов рендеринга в компьютерной графике, при котором сцена строится на основе замеров пересечения лучей с визуализируемой поверхностью.
Игрок
Логично, что если мы создаем движок от первого лица, то наш игрок — это и есть точка, из которой будут выходить лучи. Для начала нам понадобится всего три свойства: координата x
, координата y
и направление:
function Player(x, y, direction) { this.x = x; this.y = y; this.direction = direction; }
Карта
Будем хранить карту с помощью двумерного массива. В нем 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
с помощью умножения x
на коэффициент угла наклона rise / run
.
var dx = run > 0 ? Math.floor(x + 1) - x : Math.ceil(x - 1) - x; var dy = dx * (rise / run);
Прелесть этой части алгоритма в том, что размер карты не имеет значения. Мы рассматриваем только определенный набор точек на сетке — каждый раз примерно одно и то же количество. В нашем примере размер карты 32×32, но если бы он был 32 000×32 000, скорость загрузки была бы такой же.
Рисуем колонку
После того как мы отследили луч, нам нужно нарисовать все стены, которые встречаются ему на пути.
var z = distance * Math.cos(angle); var wallHeight = this.height * height / z;
Мы определяем высоту каждой стены, деля её максимальную высоту на z
. Чем дальше стена, тем короче мы её рисуем.
Откуда взялся косинус? Если мы будем использовать «чистое» расстояние от игрока до стены, то в итоге получим эффект «рыбьего глаза». Представьте, что вы стоите лицом к стене. Края стены слева и справа находятся от вас дальше, чем центр стены. Но мы же не хотим, чтобы при отрисовке стена выпирала посередине? Для того, чтобы визуализировать плоскую стену так, как мы её видим в реальной жизни, мы строим треугольник из каждого луча и находим перпендикуляр к стене с помощью косинуса. Вот так:
Слева — расстояние, справа — расстояние, умноженное на косинус угла.
В нашей статье это — самая сложная математика, с которой придется столкнуться 🙂
Визуализируем
Используем объект Camera, чтобы отрисовать карту с точки зрения игрока. Объект будет отвечать за визуализацию каждой колонки в процессе движения слева направо.
Прежде чем он отрисует стены, мы зададим skybox
— большое изображение для фона с горизонтом и звездами. После того, как закончим со стенами, добавим оружие на передний план.
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);
Задаем ширину стены в 1 пиксель.
Освещение и молнии
Освещение — это, вообще-то, работа с тенями: все стены рисуются со 100% яркостью, а потом покрываются черным прямоугольником какой-либо прозрачности. Прозрачность определяется как расстоянием до стены, так и её ориентацией (север / юг / запад / восток).
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
-координату для изображения текстуры.
Можно посмотреть результат на сайте автора, а также изучить код на GitHub.
Перевод статьи «A first-person engine in 265 lines»