3D игра на three.js, nw.js

Я решил выпустить новую версию своей старой браузерной игры, которая на протяжении пары лет пользовалась успехом в качестве приложения в социальных сетях. На этот раз я задался целью оформить ее также и в виде приложения под Windows (7–8–10) и разместить в различных магазинах. Конечно, в дальнейшем можно сделать сборки и под MacOS и Linux.

1ctjvijgbjodkdjhwszw4atwgvm.jpeg


Код игры написан полностью на чистом javascript. Для отображения 3D графики в качестве связующего звена между скриптом и WebGL используется библиотека three.js. Однако, так было и в старой, браузерной версии. Самым главным в этом проекте для меня стал повод параллельно с игрой дописать собственную библиотеку, призванную дополнить three.js средствами удобной работы с объектами сцены, их анимацией и многими другими возможностями. Я тогда забросил ее на длительное время. Пришла пора к ней вернуться.

Моя библиотека содержит удобные средства для добавления и удаления объектов со сцены, изменения свойств отдельных частей объектов (мешей), независимую от фреймрейта анимацию движения 3D объектов, шейдер небосвода с текстурой звездного неба в ночное время и многое другое. Расскажу о некоторых из них. Касательно небосвода, я реализовал его создание одной функцией, которая принимает ряд входных параметров, инициализирует шейдер, загружает текстуру облаков (если они нужны) и начинает обновлять небосвод с заданной итерацией.

Впрочем, там все немного сложнее — для периодически, но редко вызываемых функций на самом деле работает еще одна конструкция, использующая setInterval (), в которую можно накидать событий вообще с разными интервалами, а она все это сведет к общему знаменателю и будет отрабатывать в нужное время нужные события по списку. Туда же можно кинуть и интервал обновления неба. А вот движение игровых 3D объектов для большей плавности уже реализовано через requestAnimationFrame ()…

Итак, раз уж мы заговорили о небе, то начнем с него.

Небо


gexwday-od32dav2p4gb48p1nme.jpeg


Добавление небосвода на сцену происходит следующим образом.

Для начала необходимо добавить на сцену стандартный свет three.js с его максимальными (исходными) значениями яркости. Вся сцена с ее объектами, светом и прочими атрибутами, чтобы не захламлять глобальное пространство, будет храниться в пространстве имен apscene.

//Заполняющий свет (AmbLight) основной сцены
apscene.AmbLight=new THREE.AmbientLight(0xFFFFFF);
apscene.scene.add(apscene.AmbLight);

//Заполняющий свет (AmbLightBk) фоновой сцены (она используется для отображения задников и неба)
apscene.AmbLightBk=new THREE.AmbientLight(0xFFFFFF); apscene.sceneb.add(apscene.AmbLightBk);

//Туман (цвет, интенсивность) основной сцены
var SFog=new THREE.FogExp2(0xaaaaaa, 0.0007);
apscene.scene.fog=SFog;

//Туман (цвет, интенсивность) фоновой сцены
var SFogBk=new THREE.FogExp2(0xa2a2aC, 0.0006);
apscene.sceneb.fog=SFogBk;

//Свечение атмосферы
apscene.hemiLight=new THREE.HemisphereLight(0xFFFFFF, 0x999999, 1);
apscene.scene.add(apscene.hemiLight);

//Прямой солнечный свет основной и фоновой сцены задается так. У него много параметров и дополнительных свойств. Все это можно найти в документации по three.js и настроить так, как вам нужно, в том числе параметры теней, отбрасываемых объектами, освещенными этим светом...
apscene.dirLight=new THREE.DirectionalLight(...)
apscene.dirLightBk=new THREE.DirectionalLight(...)


После этого можно уже запускать анимацию небосвода с шейдерами, текстурами (блэкджеком и… ну, ладно) посредством одной моей функции:

m3d.graph.skydom.initWorld(

	//Этот параметр используется, в случае, если надо сохранить резервную копию исходных значений освещения
	{saveStart:false},
	
	{

	//ltamb (light ambient, можно задать произвольное имя) - изменение заполняющего освещения всех объектов сцены
	ltamb:{a1:-2, a2:8,  k1:0.2, k2:0.75,  obj:[
		{obj:apscene.AmbLight.color, key:['r','g','b']}
	]},
	//a1 и a2 - это крайние значения высоты солнца (altitude, которая будет приходить из шейдера неба), в диапазоне которых любой параметр, заданный в obj, изменяется в своем диапазоне (k1..k2), где коэффициенты k1 и k2 - крайние значения изменяемого параметра в долях от исходного
	//То есть, здесь под именем ltamb задано: при восходе солнца от -2 до 8 (этот параметр приходит из шейдера), свечение объектов сцены (apscene.AmbLight.color), задаваемое интенсивностью компонентов r, g и b, будет изменяться от 0.2 до 0.75 от его исходного значения (заданного выше, в инициализации света сцены, то есть от 0xFFFFFF).
	//key - это список имен ключей, используемых для доступа к данному параметру в three.js

	//Далее задаем изменение заполняющего освещения объектов фоновой сцены (apscene.AmbLightBk.color). При движении солнца (изменении его altitude) от -4 до 12 заполняющее освещение фоновой сцены будет плавно изменяться в диапазоне 0.3 ... 0.99 от его исходного значения. Ниже -4 оно, естественно, останется на уровне 0.3 от исходного, а выше 12 оно будет 0.99 от исходного
	ltambb:{a1:-4, a2:12,  k1:0.3, k2:0.99,  obj:[
		{obj:apscene.AmbLightBk.color, key:['r','g','b']}
	]},
				
	//Интенсивность прямого солнечного света будет меняться так:
	ltdir:{a1:-2, a2:8,  k1:0.0, k2:1,  obj:[
		{obj:apscene.dirLight, key:['intensity']},
		{obj:apscene.dirLightBk, key:['intensity']}
	]},
	//Интенсивность прямого солнечного света для фоновой сцены (apscene.dirLightBk) изменяется так же, как и для основной, поэтому мы просто задали его вторым параметром.
	//Опять же, интенсивность данного вида света в three.js задается одним ключом - inensity (то есть, apscene.dirLight.intensity). То есть, надо смотреть, какими ключами и что задается в three.js и прописывать их здесь.

	//Изменяющееся свечение атмосферы. На мой взгляд, его лучше всего уменьшить (до 0.15 от исходного) при просадке солнца с 12 до 8 (и, наоборот, увеличить восходе от 8 до 12), соответственно:
	lthem:{a1:8, a2:12,  k1:0.15, k2:0.3,  obj:[
		{obj:apscene.hemiLight, key:['intensity']}
	]},
	//Атмосферное свечение на фоновой сцене в данной игре не используется. Его видимость вполне реализуется шейдером небесной сферы.

	//Далее задаем изменение цвета (яркости) тумана основной сцены:
	ltambfog:{a1:-2, a2:8,  k1:0.4, k2:1,  obj:[
		{obj:apscene.scene.fog.color, key:['r','g','b']}
	]},

	//И фоновой сцены:
	ltambbfog:{a1:-2, a2:12,  k1:0.25, k2:1,  obj:[
		{obj:apscene.sceneb.fog.color, key:['r','g','b']}
	]},
	
	//Задаем изменение плотности тумана основной сцены. Пусть вечером, ночью и рано утром туман будет становиться менее плотным, чем днем.
	ltambfogd:{a1:8, a2:12,  k1:0.2, k2:0.35,  obj:[
		{obj:apscene.scene.fog, key:['density']}
	]},

	//Фоновой сцены
	ltambbfogd:{a1:6, a2:12,  k1:0.2, k2:0.28,  obj:[
		{obj:apscene.sceneb.fog, key:['density']}
	]},

	//Дополнительно можно изменять, в зависимости от времени суток (а точнее, от высоты солнца), яркость некоторых объектов, чтобы они не светились ночью, в данном случае, плоскостей-«задников» с изображением лесов и воды, которые у меня имеют имена skyplane1..6:
	planeAmb:{a1:-5, a2:12,  k1:0.5, k2:1.0,  obj:[
		{obj:apscene.user.skyplane1.material.color, key:['r','g','b']},
		{obj:apscene.user.skyplane2.material.color, key:['r','g','b']},
		{obj:apscene.user.skyplane3.material.color, key:['r','g','b']},
		{obj:apscene.user.skyplane4.material.color, key:['r','g','b']},
		{obj:apscene.user.skyplane5.material.color, key:['r','g','b']},
		{obj:apscene.user.skyplane6.material.color, key:['r','g','b']}
	]}

	//Вообще, в зависимости от высоты солнца, можно менять в игре все, что угодно.

};


В результате на сцене появится динамический небосвод с плавной сменой высоты солнца и, соответственно, сменой времени суток.

Совсем не обязательно использовать на сцене все виды освещения. И не обязательно менять все параметры в зависимости от времени суток. Но, играя их яркостью, все же, можно создать довольно реалистичную картину смены дня и ночи. Именовать параметры можно как угодно, главное — соблюдать внутри них ключи объектов как они заданы в three.js.

Как это выглядит, можно посмотреть на видео из демо сцены:


Это другая игра. Просто в ней горизонт не захламлен различными объектами и поэтому работа данного скрипта видна нагляднее всего. Но в игре, о которой идет повествование, используется ровно тот же подход. Высокая скорость течения времени здесь задана только с целью демонстрации, а так время течет, само собой, медленнее, при том же шаге итерации обновления небосвода. В этой демке, кстати, задействован шейдер воды, тоже с изменяемыми параметрами, в зависимости от высоты солнца… Но я его еще не доработал.

Производительность


Все это весьма мало требовательно к железу. Работая в браузере Chrome, оно загружает Xeon E5440 под LGA775 (и с 4 гигами оперативки) на 20%, а ядро видеокарты GT730 — на 45%. Но это чисто из-за анимации воды. Если же говорить об игре, где нет воды, но есть город, вот об этой:

0i_ksepjxwtge9qffnonerlnkes.jpeg


то в момент движения автомобиля по городу — проц 45%, видеокарта 50%. В принципе, с некоторой просадкой fps (на вид примерно до 30 кадров в секунду) это сносно работает даже на Pentium4 3GHz (1Gb RAM) и на планшете на Intel Atom 1.3GHz (2Gb RAM).

Все это железо крайне слабое и другие подобные игры на WebGL и на HTML5, даже некоторые 2D, на нем у меня тормозят безбожно, вплоть до того, что в них становится невозможно играть. Как говорится, напиши игры сам, как тебе надо, и играй.

Сцена


jbzoy1gm1aomyfdgyp7udkziole.jpeg


3D сцена в three.js представляет собой объект scene и массив его children — это, собственно, все 3D модели, загруженные в сцену. Чтобы не прописывать вызов загрузчика для каждой моделей, я решил, что вся сцена игры будет задаваться в виде некой конфигурации, одним большим ассоциативным массивом locd:{} (типа — location data), в котором будут содержаться все настройки — света, путей предзагружаемых текстур и изображений для интерфейса, путей ко всем моделям, которые должны загрузиться на сцену, и другое. В общем, это полная конфигурация сцены. Она задается один раз в js-файле игры и скармливается моему загрузчику сцены.

И в этом объекте locd:{}, в частности, содержатся пути к отдельным 3D моделям, которые необходимо загрузить. Нулевым элементом идет общий путь, а затем — относительные пути для каждого объекта, типа:

['path/myObj', scale, y, x,z, r*Math.PI, 1, '', '', '', 1, ['','','',''], 'scene']


Подразумевается, что все модели экспортированы из 3D редактора в формат json, то есть, имеют пути типа path/myObj.json. Далее следует масштаб (поскольку в редакторе можно сохранить с неподходящим для данной игры масштабом), положение объекта по высоте (y), по осям (x) и (z), затем идет угол поворота ® модели по (y), ряд опциональных параметров и имя сцены, куда грузить модель — на основную сцену (scene) или на фоновую (sceneb).

Да, надо было реализовать это не в виде простого, а в виде ассоциативного массива. Так непонятен порядок следования параметров и без документации, или хотя бы без вида функции, которая принимает эти параметры, не разберешься. Думаю, в будущем переделаю эти строки в ассоциативные массивы. А пока это выглядит так:

landobj: [ 
	['gamePath/'],
	[
		['landscape/ground', 9.455, 0, 0,0, 0*Math.PI, 1, '', '', '', 1, ['','','',''], 'scene'],
		['landscape/plants', 9.455, 0, 0,0, 0*Math.PI,1, '', '', '', 1, ['','','',''], 'scene'],
		['landscape/buildings/house01', 2, 0, -420,420, -0.75*Math.PI, 1, '', '', '', 1, ['','','',''], 'scene'],
		...
	]
],


Эти модели загружаются на сцену и размещаются в заданных здесь координатах в пространстве. В принципе, все модели могут загружаться как единый объект, то есть, экспортироваться из редактора как целая игровая сцена и загружаться в координаты (0;0;0). Тогда будет только одна строка: landscape/ground — у меня ground.json — это и есть основная часть игрового мира. Но в этом случае будет затруднительно манипулировать отдельными объектами сцены, так как нужно будет предварительно посмотреть в консоли браузера и запомнить какой из children этого огромного ground чем является. И далее обращаться к ним по номерам. Поэтому перемещаемые игровые модели лучше загружать отдельными объектами. Тогда к ним можно будет обращаться по именам из ассоциативного массива, который будет специально для этого автоматически создан.

Полностью конфигурация игры может выглядеть, например, так:

locd:{
	//имена
	name: 'SeaBattle',
	type: 'game',
	menulabel: '',
	
	//координаты начальной позиции игрока
	x:-420, y:70, z:-420,
	rot: -0.5,

	//дистанция захвата интерактивных объектов
	intsdistance: 200,
	
	//свет
	ambLtColor: 0xFFFFFF,
	ambLtColorBk: 0xFFFFFF,
	lightD: [0xDDDDDD,0.3,1000, 200000,0.3,-0.0003, -190,200000,-140,0,0,0, 200,-200,200,-200],
	lightDBk: [0xFFFFFF,0.3,10000, 40000,0.3,-0.0035, -190,1200,-140,0,0,0, 50000,-50000,50000,-50000],
	lightH: [0xFFFFFF,0x999999,1, 0,500,0],
	
	//контейнер для активных точечных источников света
	lightsP: [], 
	//конфигурация каждого точечного источника света
	lightsPDynamicAr: [
		[0xffffff, 4, [0, -2000, 0], [50, 1.5] ] //[[distance], [decay]]
	],
	//координаты всех возможных появлений на сцене точечных источников света (x,y,z)
	userPointLights: [
		[0, -2000, 0]
	],
	
	//направленные источники света
	lightsS: [
		[0xffffbb, 1.0, [0, 250, 180], [0, 0, 180], 0.5, 600,600,600,  -0.0005]
	],
	
	//настройки теней
	shadowMapCullFace:0,
	shadowsMode: 'all', //all,list,flag
	
	//предзагружаемые изображения
	imagePaths: [
		'game/img/',
		'interface.png', 'interface2.png'
	],
	
	//предзагружаемые 3D модели
	landobj: [ 
		['game/models/'],
		[
			['landscape/land',1, 0, 0,0, 0.0*Math.PI,1,'','','',1,['','','',''],'scene'],
			['landscape/sbp',1, 0, 0,180, 1.0*Math.PI,1,'','','',1,['','','',''],'scene'],
			['landscape/sbu',1, 0, 0,1120, 1.0*Math.PI,1,'','','',1,['','','',''],'scene']
		]
	],
	
	//догружаемые модели после загрузки основных и отображения сцены
	staffobj: [
		['game/models2/'],
		[
		]
	],
	
	//интерактивные модели
	progobj: [
		[
		]
	]

},


Да, все эти подмассивы лучше переделать в ассоциативные массивы, а то непонятен порядок следования параметров в них…

3D модели


fzfmqlr4yfw3_tkm79xjwlb_ccw.jpeg


Еще из интересного. Загрузка моделей. Моя библиотека принимает 3D модели с текстурами и автоматически задает некоторые параметры их отдельным элементам (мешам), в зависимости от имен. Дело в том, что, если для модели, например, задано отбрасывать тень, то ее будет отбрасывать каждый меш, входящий в ее состав. Не всегда нужно, чтобы вся модель полностью отбрасывала тень или приобретала еще какие-либо свойства, сильно влияющие на производительность. Поэтому, если включить некий флаг сигнализирующий о том, что необходимо рассматривать каждый меш в отдельности, то при загрузке можно установить, какой меш будет обладать тем или иным свойством, а какой нет. Ну, например, совершенно нет необходимости в том, чтобы тень отбрасывала плоская горизонтальная крыша дома или множество каких-нибудь мелких несущественных деталей модели на фоне крупной. Все равно этих теней игроку будет не видно, а мощность видеопроцессора на их обработку будет задействована.

Для этого в графическом редакторе (Блендере, Максе и т.д.) можно сразу задать имена мешей (в поле name объекта) по определенному правилу. Там должен быть символ подчеркивания (_). В левой части должны идти условные управляющие символы, например: d — doubleside (меш двусторонний, в противном случае — односторонний), c (cast shadow) — отбрасывает тень, r (receive shadow) — принимает тени. То есть, например, имя меша трубы в составе дома может быть таким — cr_tube. Используются и многие другие буквы. Например, «l» — это коллайдер, то есть, стена дома, имея имя crl_wall01, не даст игроку пройти сквозь себя, а также будет отбрасывать и принимать тень. Делать коллайдерами, например крышу или ручку двери и тем самым ухудшать производительность нет никакой необходимости. Как вы уже поняли, моя библиотека при загрузке модели парсит имена мешей и придает им соответствующие свойства на сцене. Но для этого надо перед экспортом модели из 3D редактора грамотно именовать все меши. Это существенно сэкономит производительность.

Все управляющие флаги для мешей внутри объекта:

col_… — коллайдер (collider). Такой меш будет выведен просто как прозрачный, невидимый коллайдер. В редакторе он может выглядеть как угодно, важна только его форма. Например, это может быть параллелепипед вокруг всей модели, если нужно, чтобы игроку нельзя было пройти сквозь эту модель (здание, большой камень и т.д.).

l_… — коллайдер-объект (collidable object). Придание любому мешу свойства коллайдера.

i_… — пересечения (intersections). Меш будет добавлен в список intersections, что можно использовать, например, для клика на нем, то есть для придания интерактивности в игре.

j_… тоже пересечения (intersections). То же, что выше, только более новая версия — с улучшенным алгоритмом поиска пересечений в игре и меньшим потреблением ресурсов.

e_… — пересечения для дверей домов (entrance/exit). Исключает отработку пересечений по другим мешам объекта. Используется, если у домов нужно в какой-то момент сделать интерактивными только двери, исключив все остальные интерактивные элементы. При фантазии можно придумать этому и массу других применений.

c_… — отбрасывать тени (cast shadows). Меш отбрасывает тень.

r_… — принимать тени (receive shadows). Меш принимает тени от всех других мешей, которые их отбрасывают.

d_… — двусторонний (double sided). Видимый с обеих сторон, текстура накладывается с обеих сторон.

t_… — прозрачный (transparent), если для всего объекта задано значение alphatest в three.js.

u_… — прозрачный (transparent), с фиксированной плотностью (0.4), если для всего объекта не задано alphatest в three.js.

g_… — стекло (glass). Устанавливается фиксированная прозрачность (0.2).

h_… — невидимый (hidden). Для частей объекта (мешей) которые должны быть скрыты при добавлении объекта на сцену. Заносится в список скрытых.

v_… видимый (visible). Все объекты, кроме помеченных «h», и так видимы, но с флагом «v» они заносятся в отдельный список видимых для дальнейшего скрытия или других манипуляций.


В итоге, имя меша вполне может быть каким-нибудь таким: crltj_box1 (отбрасывает, принимает тень, коллайдер, прозрачный, интерактивный). А другой меш в составе той же модели: cr_box2 (только отбрасывает и принимает тени), Естественно, управляющие символы можно задавать в любом порядке. Таким образом, уже из редактора можно управлять будущим отображением частей объекта в игре, а точнее, некоторыми их свойствами, экономя, при этом, вычислительную мощность.

Суть игры


Смысл, собственно, той игры, о которой повествование, заключается в том, чтобы передвигаться по периметру квадратного поля и покупать предприятия. Поле выполнено в виде 3D улиц. Экономическая модель игры существенно отличается от оных ей подобных. В моей игре, когда кто-то открывает предприятие, ваша ожидаемая прибыль падает. И наоборот, когда что-то открываете вы, то — возрастает. Все расчеты по прибыли и убыткам производятся в Налоговой инспекции на поле Старт. Также можно брать кредит в банке, торговать ценными бумагами и проворачивать ряд других дел. Я улучшил поведение ИИ по сравнению со старой версией. Переделал почти все 3D модели и текстуры игры и оптимизировал производительность. Сделал больше настроек и много чего еще.

Анимация


lw7um_phbrcpp-yx78ec5ihw9jy.jpeg


Для анимации движения объектов игры используется простейший движок, который с заданной частотой кадров (она ограничена 60-ю) изменяет любые числовые параметры за заданный интервал времени, выдавая, при этом, в обработчик промежуточные значения. А в обработчике происходит, например, отображение модели.

К примеру. Нам нужно переместить в пространстве объект obj из позиции (10;10;50) в точку (100;300;60). Задаем 3 параметра путем указания их начальных и конечных значений. Координата x у нас будет изменяться от 10 до 100, y — от 10 до 300 и z — от 50 до 60. И все это должно произойти, скажем, за 4 секунды.

m3d.lib.anim.add(
	'moveobj', 1, 4000, 'and', {userpar1:111, obj:my3DObject},
	[
		{lim1:10, lim2:100,  sstart:10, sfin:100, t:0},
		{lim1:10, lim2:300,  sstart:10, sfin:300, t:0},
		{lim1:50, lim2:60,  sstart:50, sfin:60, t:0}
	], 
	function(){ myPeriodicFun(this); },
	function(){ myFinishFun(this); }
);

m3d.lib.anim.play();


Первая строка из 5 параметров: moveobj — имя анимации (любое), 1- номер потока (можно анимировать объекты параллельно в неограниченном количестве потоков), 4000 — время анимации 4 секунды, and — пока неиспользуемый параметр, который в будущем будет отвечать за логику перехода между анимациями внутри одного потока, userpar — любой ассоциативный массив, который будет передаваться в обработчик как параметр, например, с предрасчитанным радиусом, синусами, косинусами и вообще любыми предрассчитанными для данной анимации величинами, чтобы их не вычислять во время каждой итерации. Или со ссылкой на 3D объект, который, собственно, и будем анимировать.

Далее идет массив с изменяемыми параметрами. Мы уславливаемся, что первый параметр — это изменение координаты x, второй — y, третий — z. Прописываем для каждого в lim1 и lim2, от какой и до какой величины он будет изменяться. В sstart и sfin указываем те же значения. Здесь можно указать старт, например, с какой-либо другой величины, тогда параметр будет «прокручиваться» по кругу с нее и до нее же, минуя lim2 и начав новый «оборот» с lim1. Ну, например, это надо, если у нас анимация зациклена между какими-то величинами (lim1 и lim2), но стартовать нам ее надо не с начала (то есть, не с lim1), а с какого-то промежуточного значения.

t:0 как раз задает то, что анимация по данному параметру выполняется 1 раз, согласно общему времени (4000), как бы, растягиваясь на него. Если мы зададим другое число, меньше основного времени, то данный параметр будет зациклен и будет повторяться вплоть до истечения времени основной анимации (4000). Это удобно, например, для задания вращения объекту, когда угол должен многократно пересекать рубеж 360 градусов и сбрасываться на 0.

Далее идут 2 коллбека — тот, который будет выполняться с каждой итерацией и тот, что выполнится один раз по завершении всей анимации (точка выхода).

Первый коллбек myPeriodicFun (this), например, может быт таким:

myPeriodicFun:function(self) {
	var state=self.par[0].state, state_old=self.par[0].state_old;
	var state2=self.par[1].state, state_old2=self.par[1].state_old;
	var state3=self.par[2].state, state_old3=self.par[2].state_old;
	if ((state!=state_old)||(state2!=state_old2)||(state3!=state_old3)) {
		var obj=self.userpar.obj;
		obj.position.x=state;
		obj.position.y=state2;
		obj.position.z=state3;
		ap.cameraFollowObj(obj);
	};
},


То есть, при каждой итерации движения в эту функцию кидается параметр (self), содержащий рассчитанные промежуточные значения по всем заданным параметрам анимации: self.par[0].state, self.par[1].state, self.par[2].state. Это и есть наши x, y и z в текущий момент времени. Например, при длительности анимации 4 секунды, через 2 секунды x будет равен (100–10)/2 = 45. И так для всех координат. Соответственно, в myPeriodicFun просто отображаем наш объект в этих координатах. Если браузер начнет лагать или просто будет медленно работать на данном железе, не страшно: общее время анимации не изменится, упадет только фреймрейт и картинка превратится в слайд-шоу.

Для чего проверять f ((state!=state_old)…, то есть, не равно ли новое вычисленное значение старому (в state_old запоминается вычисленное в предыдущей итерации), ну, например, для того, чтобы, если какой-то параметр изменился менее, чем на единицу, то не перерисовывать весь объект и не тратить на это мощь системы. А движок анимации выдает в state и state_old целые числа, которые, скажем, можно интерпретировать как шаг, равный пикселю. А если объект относительно предыдущего положения не сместился даже на 1 пиксель, то и нет необходимости его перерисовывать, поскольку его положение на экране не меняется.

В общем, под анимацией понимается простое изменение любого количества параметров за определенное время с выдачей их промежуточных значений в коллбек-функцию. Например можно добавить еще 4-й параметр, который будет отвечать за угол вращения объекта. А можно вообще в одну анимацию запихнуть параметры множества объектов, если они движутся как-то единообразно. Можно ставить анимации в разные потоки, тогда они будут обрабатываться параллельно. Можно добавлять (m3d.lib.anim.add ()) в один поток целую последовательность анимаций, и они выполнятся друг за другом. Причем, в каждом потоке будет своя независимая последовательность. Главное, чтобы хватило мощности системы и все не превратилось в слайд-шоу.

P.S. Потоки здесь реализуются просто последовательным перебором при каждой итерации всех параллельных анимаций и вычислением для каждой из них промежуточных значений всех их параметров. Т.е., никакой реальной многопоточности в javascript нет.

Этот же «движок» можно использовать и для анимации элементов интерфейса, задав им изменение 2-х координат на плоскости экрана и отображая эти элементы в коллбэк-функциях по получаемым промежуточным значениям. Что, собственно, и сделано в моей игре.

Динамические тени


fd4c1-dofkcz8fsysds0pbi_-eo.jpeg


Отображение теней на всей сцене оказалось настолько расточительным занятием, что при их включении fps падал в несколько раз. Это никуда не годится. Будем отображать тени от объектов в неком небольшом квадрате вокруг игрока. Скажу сразу, что такой прием существенно повышает фреймрейт.

Тут ничего сложного. Тени three.js отбрасываются внутри некой камеры теней, заданной параллелепипедом (shadowCameraLeft, shadowCameraRight, shadowCameraTop, shadowCameraBottom). Ее можно растянуть на всю сцену, а можно сделать так, чтобы она следовала за основной камерой и тени бы отбрасывались только вокруг нее. Единственное, чем я дополнил эту систему — это шагом, через который тени будут обновляться. Совершенно незачем делать это обновление при каждом подергивании игрока, так как это нагружает систему вычислениями. Пусть он преодолеет некоторое минимальное расстояние по любой из трех осей, чтобы тени обновились.

В моей библиотеке при инициализации 3D мира создается объект contr, который в любой момент времени содержит координаты камеры. Также там можно задать пользовательский параметр contr.cameraForShadowStep, который содержит шаг позиции камеры, при котором меняется положение камеры теней.

Если, скажем, параллелепипед камеры теней имеет размеры 700×700x700, то contr.cameraForShadowStep можно задать равным, например, 20. И при сдвиге игрока на 20 по любой из осей, исходная позиция вновь запомнится и обновятся тени вокруг игрока. Масштаб 3D мира может быть любым, в зависимости от того, в каком масштабе создавались все модели в 3D редакторе. И вполне вероятно, что вместо 700×700x700 и 20, нужно будет использовать 7000×7000x7000 и 200. Но сути это ни коем образом не меняет.

К слову, при движении солнца по небосводу тени обновляются независимо от этой системы, так как там должно меняться направление теней. То есть, будут обновляться даже в том случае, если игрок стоит без движения. Там тупо вызывается функция обновления теней по периоду обновления неба.

Система точечных источников света


kgfabepvwgn_zxwfctnyh2rce7g.jpeg


Наличие более десятка точечных источников света на сцене так же сильно бьет по fps, как и динамические тени. И даже делает невозможной игру на стареньком «Пеньке». Причем, неважно, находятся ли эти источники в поле видимости игрока (дальность прорисовки мира может задаваться) или же на значительном удалении. Если они тупо присутствуют на сцене, то все работает медленно. Поэтому я предусмотрел в меню настроек игры, которое можно вызвать до загрузки 3D мира, варианты количества таких источников (2, 4 или 8) под названием «Свет фонарей».

mr-gsrw4vfm6qjobtc5a36p432s.jpeg


Таким образом, в ночное время включить все расставленные на сцене фонари одновременно не получится. Выше, в описании схемы инициализации мира я привел массивы userPointLights и lightsPDynamicAr. В userPointLights задаются массивом координаты всех ламп фонарей на сцене. А lightsPDynamicAr содержит настройки света для всех 8 экземпляров. В зависимости от настройки количества фонарей, библиотека будет брать первые из них и добавлять на сцену в поле видимости игрока.

Фактически во время движения игрока происходит поиск 2–8 ближайших к игроку фонарей по массиву координат фонарей userPointLights. И точечные источники света перемещаются под них. Иначе говоря, 2–8 ламп освещения следуют за игроком, окружая его. Причем, это тоже делается не при каждом кадре fps, а с задаваемым шагом. Совершенно незачем запускать функцию поиска 60 раз в секунду, тем более, если игрок не движется — тогда и пусть себе горят уже найденные вокруг него фонари.

Вот так это выглядит в движении (Xeon E5440, GeForce GT730):

Сборка дистрибутива


Поскольку я не использую никакую навороченную среду разработки (кроме продвинутого блокнота), то я написал bat-файл, в котором вызывается Google Closure Compiler для обфускации кода каждого *.js файла. А далее там же вызывается nwjc.exe из комплекта nw.js — для компиляции js в бинарники (*.bin). приведу пример для одного из файлов:

java -jar D:\webservers\Closure\compiler.jar --js D:\webservers\proj\m3d\www\game\bus\bus.js --js_output_file D:\webservers\proj\nwProjects\bus\game\bus\bus.js

cd D:\«Program Files»\Web2Exe\down\nwjs-sdk-v0.35.5-win-ia32

D:\«Program Files»\Web2Exe\down\nwjs-sdk-v0.35.5-win-ia32\nwjc.exe D:\webservers\proj\nwProjects\bus\game\bus\bus.js D:\webservers\proj\nwProjects\bus\game\bus\bus.bin

del D:\webservers\proj\nwProjects\bus\game\bus\bus.js


Далее я использую простенькую утилиту Web2Executable для создания exe файла со сборкой под Windows. Версию nw.js я выбрал 0.35.5, несмотря на то, что доступны и более новые. Какого-то эффекта я от них не заметил, кроме увеличения размера сборки.

hk_2ed5imb6en8ffx6vtfx_7t-o.jpeg


Утилита способна сама закачивать выбранную версию nw.js в указанную папку. На выходе получается сборка. В исполняемом файле объемом 35 мегабайт содержится, собственно, сама игра. Все остальное — node-webkit. В папке locales содержатся файлы, очевидно, с какими-то ресурсами на разных языках. Я их поудалял и оставил только относящиеся к английскому. К слову, это ничуть не помешало запускать и русскоязычную версию игры (в игре язык переключается между русским и английским). Для чего все эти файлы тогда, я не знаю. Но без английского ничего не запускается.

Вся сборка в итоге заняла 167 Мб.

jpevqq5deupdq0zkm7naffok4fa.jpeg


Затем я упаковал все в один исполняемый файл-дистрибутив при помощи одной из бесплатных утилит, предназначенных для этой цели, и на выходе получил файл Businessman3DSetup.exe объемом 70,2 Мб.

sfxdnnsvu3fpp9ukkzsius2vqsm.jpeg

Публикация


Я разослал сборку в разные магазины приложений. В большинстве из них еще идет процесс модерации моей игры. На данный момент ее пока опубликовал только itch. Предупреждаю сразу, игра платная, цена — $3. Покупок пока нет, но я пока еще и не занимался ее продвижением. GOG в публикации отказал, ссылаясь на то, что игра довольно простая и нишевая. Я, в принципе, согласен. Epic Store, думаю, сделает то же.

Опубликованная версия игры — однопользовательская, с ботами в количестве от 1 до 5. Язык — русский и английский. Я намерен допилить сетевую версию. Но пока в раздумье — выпустить ее в виде такого же приложения или в форме браузерной веб-версии, доступной сразу и в Windows, и в Linux, и в iOs и в MacOs, и вообще везде, где браузер поддерживает WebGL. Ведь, по сути, webkit — браузер и игра прекрасно работает в нем, в Firefox, в Edge и даже в IE11 под Windows, правда, в последнем — весьма медленно.

Выводы


kwcfwmtwbwugbnnsb3gol4bshkk.jpeg


Наверно я пока не готов выложить свой движок для всеобщего использования, поскольку он еще не закончен. Я собираюсь сначала написать на нем еще одну игру, как раз, ту, из демо-ролика, про корабли, потому что на той игре можно «обкатать» работу с шейдером воды. Кроме того, я планирую реализовать там простейший физический движок. Да и нужно еще допилить все остальные возможности, исправить все недочеты. И лучше это сделать на двух играх, чем на одной, поскольку возможны какие-то нюансы. А пока мой движок, на мой взгляд, все еще слишком сильно заточен под одну игру.

Кроме того, я вообще не уверен в том, что все это кому-нибудь нужно. Если посмотреть трезво, то никто не пишет игры на чистом javascript. Но мне это нравится, потому что игры получаются довольно легкими и быстрыми для браузера. Они быстро загружаются, не требуют много оперативной памяти и довольно шустро работают, по сравнению с конкурентами на html5, даже если сравнивать с 2D. Думаю, я на всех этих своих наработках выпущу еще не одну браузерную (и не только) игру.

© Habrahabr.ru