На днях мистер Дуб принял мой первый pull request с примером в Three.js, и на радостях я решился написать о нём хабропост. Если Вам вдруг захочется написать трёхмерный платформер на Three.js, но Вы не особо представляете себе как это сделать, этот пример — для Вас:
Весь код примера занимает менее 300 строк, щедро разбавленных переносами, разобраться в которых самостоятельно не составит особого труда. Однако, чтобы ещё больше облегчить Вашу участь, я напишу немного ниже пару слов о ключевых моментах.
Предыстория
Мы все слышали о людях, способных написать шутер за два дня, но можем ли мы сами стать в один ряд с легендами? Чтобы проверить свои силы, я обложился уроками по Three.js гуглом и начал ваять свой 2х-дневный шедевр. Однако через часика два мне надоело, я закоммитил что там было и пошёл подышать свежим воздухом почитать интернеты. Так повторялось каждый раз, когда я возвращался к этой затее. Проходили дни, потом недели. Но капля продолжала точить камень, и где-то через месяц я таки выточил свой шутер, в котором можно набегать и расстреливать караваны монстров из дробовика.
Теперь было самое время оглянуться на проделанный путь и подумать, что я сделал сносно, а где повернул не туда. Собственно, пример платформера, о котором речь в этой статье — одна из вещей, попавших, как мне кажется, в первую категорию.
Так шутер или платформер?
Возможно Вы спросите меня, почему я упорно называю по сути упрощённую версию своего шутера платформером. Мистер Дуб не только спросил, но и заставил меня переименовать пример обратно в шутер перед тем, как принять pull request. И тем не менее, я не считаю этот пример шутером. Как минимум потому, что в нём нельзя ни в кого стрелять. Зато можно бегать и прыгать по трёхмерной платформе. Код примера легко переделать под игру от третьего лица, добавив модель игрока и манипулируя ей вместо камеры, однако мне кажется это не принципиально.
Короче, Склифосовский!
Да, я малость отвлёкся от темы. Итак, чтобы сделать платформер, первым делом мы должны добавить в игровой мир хотя бы одну платформу. Дело это нехитрое, взял 3D модель, экспортнул в свой любимый формат (из числа babylon, ctm, dae, obj, ply, stl, vtk или wrl), загрузил в редактор Three.js, снова экспортнул, и загружай себе на здоровье. Тут есть два варианта:
- Сначала загрузить платформу, потом создать сцену и добавить туда платформу
- Создать сцену и добавить на неё платформу, а потом загрузить её в фоновом режиме
Первый вариант, ясное дело, идеологически более правильный, однако большинство примеров Three.js (включая этот) не заморачиваются и работают по второму сценарию. Надо отметить, что особой разницы в коде между 1 и 2 как бы и нет — просто в первом случае Вам следует перенести вызов инициализации сцены в обработчик загрузки, а во втором случае надо в основном цикле добавить костыль проверку на состояние платформы, чтобы не улететь далеко вниз, пока она не загрузилась. Я пошёл именно по этому пути, т к правильная реализация первого варианта в случае предзагрузки множества ресурсов всё равно потребует намного больше кода и/или сторонних библиотек.
function makePlatform( jsonUrl, textureUrl, textureQuality ) {
var placeholder = new THREE.Object3D();
var texture = THREE.ImageUtils.loadTexture( textureUrl );
texture.anisotropy = textureQuality;
var loader = new THREE.JSONLoader();
loader.load( jsonUrl, function( geometry ) {
geometry.computeFaceNormals();
var platform = new THREE.Mesh( geometry, new THREE.MeshBasicMaterial({ map : texture }) );
platform.name = "platform";
placeholder.add( platform );
});
return placeholder;
};
Для ускорения загрузки я удалил нормали из json файла — поэтому Вы видите тут вызов computeFaceNormals — а platform.name устанавливается для упомянутой выше проверки наличия платформы. Без этого всего код мог бы выглядеть так:
loader.load( jsonUrl, function( geometry ) {
placeholder.add( new THREE.Mesh( geometry, new THREE.MeshBasicMaterial({ map : texture }) ) );
});
Ладно, допустим Вы самостоятельно создали сцену, добавили в неё камеру и платформу. Далее, Вы должны заставить игрового персонажа как-то по ней двигаться, не пролетая и не проваливаясь сквозь неё. В этом деле Вам поможет класс Raycaster. Как несложно догадаться из названия, он рассчитывает пересечения заданного луча с выбранной геометрией, В данном случае мы просто направляем луч вниз, и находим ближайшее пересечение с платформой:
Просто, но есть нюансы. Например, нельзя использовать положение персонажа в качестве начала луча — в этом случае Вы не сможете найти пересечение с платформой, если персонаж по какой-либо причине провалится хотя бы на миллиметр, и отправите его в свободное падение вместо того, чтобы вытолкнуть обратно на платформу. Соответственно, начало луча должно находиться сверху, на высоте «птичьего полёта».
var raycaster = new THREE.Raycaster();
raycaster.ray.direction.set( 0, -1, 0 );
var birdsEye = 100;
...
// далее, в цикле
raycaster.ray.origin.copy( playerPosition );
raycaster.ray.origin.y += birdsEye;
var hits = raycaster.intersectObject( platform );
В случае многоэтажной архитектуры уровня эта высота, очевидно, ограничена минимальным расстоянием между платформами по вертикали. Далее, следует тщательно продумать, когда принимать решение о выталкивании провалившегося персонажа наверх. Если не ограничить максимально допустимую глубину «провала», персонаж будет мгновенно телепортироваться на платформу, просто зайдя (или залетев) под неё; если же ограничить её слишком сильно, персонаж сможет легко проходить сквозь платформу при приземлениях после прыжков.
var kneeDeep = 0.4;
...
// далее, в цикле
// проверяем, сверху ли мы, или хотя бы не глубже чем по колено в платформе
if( ( hits.length > 0 ) && ( hits[0].face.normal.y > 0 ) ) {
var actualHeight = hits[0].distance - birdsEye;
// если не слишком глубоко, вытаскиваем персонажа наверх
if( ( playerVelocity.y <= 0 ) && ( Math.abs( actualHeight ) < kneeDeep ) ) {
playerPosition.y -= actualHeight;
playerVelocity.y = 0;
}
}
Внимательный читатель спросит, зачем тут проверка на playerVelocity.y <= 0? Ответ: для того, чтобы не создать проблем с отрывом от платформы при прыжке.
Теперь, собственно, надо заставить персонажа перемещаться в пространстве, подчиняясь базовым законам школьного курса физики. Положим, что в любой момент у персонажа известна скорость playerVelocity
и положение в пространстве playerPosition
; тогда рассчёт движения персонажа на первый взгляд мог бы выглядеть так (псевдокод):
if( в воздухе ) playerVelocity.y -= gravity;
playerPosition += playerVelocity * time;
if( на платформе ) playerVelocity *= damping;
Увы, и тут всё не так просто. Читателям с нешкольным образованием или ветеранам игростроя этот псевдокод известен под названием «метод Эйлера», а также известно что этот метод — просто отстой. И вот почему (картинка стырена с википедии):
Как видим, рассчётная траектория со временем всё сильнее расходится с ожидаемым результатом. Само по себе это обстоятельство не так страшно — страшным его делает одна скромная переменная — time
. Представим себе, как изменится эта картинка, если time
уменьшить на 10% (пересесть в более быстрый браузер, например):
Как видим, запустив игру в firefox, мы получим одну динамику, а запустив её в chrome — совершенно иную. Поведение персонажа будет «плавать» в зависимости от интенсивности фоновых задач и расположения звёзд. Что же делать?
Выход есть, и довольно простой. Необходимо заменить рассчёт с длинным переменным шагом time
на несколько рассчётов с коротким фиксированным шагом. Например, если два последовательных интервала между отрисовками составляют 19 и 21 мс, мы должны рассчитать 3 шага по 5 мс для первой отрисовки и, добавив оставшиеся 4 мс к 21, рассчитать 5 шагов по 5 мс для второй.
var timeStep = 5;
var timeLeft = timeStep + 1;
...
function( dt ) {
// та самая проверочка ;)
var platform = scene.getObjectByName( "platform", true );
if( platform ) {
timeLeft += dt;
// несколько шагов фиксированной длины
dt = 5;
while( timeLeft >= dt ) {
// метод Эйлера
...
timeLeft -= dt;
}
}
}
На этом практически всё, Вам осталось лишь задать параметры движения персонажа (playerVelocity
например) в ответ на WASD или что-то подобное.
Ах да, совсем забыл. Полосатые выступы в примере отправляют персонажа в прыжок через всю платформу. Как? Всё очень просто — при приближении персонажа к выступу к playerVelocity
добавляется заранее подобранная вертикально-наклонная составляющая, которая гарантированно (благодаря вышеописанной схеме с фиксированным шагом) доставит его в заданную точку, подобно артиллерийскому снаряду. Никаких особых ухищрений не надо — всё уже и так работает.
Теперь точно всё. Читайте моё, пишите своё, критика приветстуется. До связи!
Автор: makc3d