Створюємо ігровий движок з видом від першої особи за 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 адреса не оприлюднюватиметься. Обов’язкові поля позначені *