В этой статье мы создадим небольшой игровой движок с видом от первого лица без сложной математики и техник 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»