Створюємо ігровий движок з видом від першої особи за 265 рядків коду на JavaScript


Дізнайтесь більше про нові кар'єрні можливості в EchoUA. Цікаві проекти, ринкова оплата, гарний колектив. Надсилайте резюме та приєднуйтеся до нас.

У цій статті ми створимо невеликий ігровий движок з видом від першої особи без складної математики і техніки 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”

Київ, Харків, Одеса, Дніпро, Запоріжжя, Кривий Ріг, Вінниця, Херсон, Черкаси, Житомир, Хмельницький, Чернівці, Рівне, Івано-Франківськ, Кременчук, Тернопіль, Луцьк, Ужгород, Кам'янець-Подільський, Стрий - за статистикою саме з цих міст програмісти найбільше переїжджають працювати до Львова. А Ви розглядаєте relocate?


Залишити відповідь

Ваша e-mail адреса не оприлюднюватиметься. Обов’язкові поля позначені *