Сегодня 2 октября, среда ГлавнаяНовостиО проектеЛичный кабинетПомощьКонтакты Сделать стартовойКарта сайтаНаписать администрации
Поиск по сайту
 
Ваше мнение
Какой рейтинг вас больше интересует?
 
 
 
 
 
Проголосовало: 7275
Кнопка
BlogRider.ru - Каталог блогов Рунета
получить код
Andrii Pashentsev's blog
Andrii Pashentsev's blog
Голосов: 0
Адрес блога: https://andyps.github.io/
Добавлен: 2016-08-06 16:37:27
 

Введение в BabylonJS

2016-08-04 03:00:08 (читать в оригинале)

В этой статье мы познакомимся с популярным WebGL-фреймворком BabylonJS на примере создания прототипа игры. Полные листинги программы доступны на github.

О BabylonJS

BabylonJS является Open Source проектом, распространяемым под лицензией Apache License 2.0.
Исходные коды на github.
Фреймворк обладает богатыми возможностями, которые перечислены прямо на официальном сайте и является отличным средством для разработки трехмерных и двумерных игр.

“A complete JavaScript framework for building 3D games with HTML5, WebGL and Web Audio”

Некоторые возможности:
набор готовых мешей, различных источников света, материалов, различные виды камер в том числе для мобильных устройств, геймпадов и устройств VR, возможность загрузки мешей с файлов, анимационный движок, аудио движок, picking, встроенный обработчик коллизий, интеграция с физическими движками, спрайты и 2d api, система частиц, пользовательские материалы и шейдеры, карты высот, постобработка, туман, карты теней и, что очень важно, многое сделано для оптимизации приложений, есть специальная панель отладки.
У фреймворка отличная документация и набор обучающих материалов на официальном сайте, отзывчивый форум, есть roadmap.

Что будет сделано

Итак, что же за игру мы сделаем.
Главным персонажем в игре будет такой себе трехмерный вариант известного pacman-а, который будет собирать монетки. Когда все монетки на уровне собраны, игра переходит на следующий уровень. В игре очень просто добавить свой уровень.
Конечно же будут враги, столкновений с которыми нужно избегать.
Будет использован физический движок.

Базовые понятия и структура приложения

Самое первое, что нужно сделать - подготовить html с элементом canvas и подключить библиотеку Babylon.js.

<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PACMAN3D</title>
<link href="main.css" rel="stylesheet" type="text/css" charset="utf-8" >
<script src="vendor/babylonjs/babylon.2.4.js"></script>
<script src="vendor/babylonjs/cannon.js"></script>

<script src="levels.js"></script>
<script src="Level.js"></script>
<script src="GameObject.js"></script>
<script src="Player.js"></script>
<script src="Block.js"></script>
<script src="Coin.js"></script>
<script src="EnemyBrain.js"></script>
<script src="Ghost.js"></script>
<script src="pacman3d.js"></script>
</head>
<body>
    <canvas id="gameCanvas" oncontextmenu="return false;">Browser not supported</canvas>
</body>
</html>

Кроме самого фреймворка, подключаются сразу библиотека физического движка cannon.js, а также файлы игры.
Для каждой сущности будут созданы отдельные классы и файлы.
Player.js содержит класс Player, представляющий собой игрового персонажа (или PC).
Coin.js содержит класс Coin для создания монет.
Block.js отвечает за прорисовку блоков платформы, по которой перемещаются персонажи.
Ghost.js соответствует классу Ghost - это враги-привидения (т.е. NPC, NPC в игровой терминологии - неуправляемый игроком персонаж).
EnemyBrain.js позволяет наделить всех NPC зачатками AI (искусственный интеллект). Благодаря EnemyBrain, все NPC умеют передвигаться по платформе, не падая вниз, выбирая правильное направление движения.
Все вышеназванные классы, кроме EnemyBrain, наследуют класс GameObject, находящийся в файле GameObject.js.
Файл Level.js соответствует классу Level. Данные, согласно которым конструируются уровни, находятся в файле levels.js. Именно этот файл нужно отредактировать, чтобы добавить новые уровни или изменить/удалить существующие.
pacman3d.js - это главный файл, с которого и начнем рассмотрение JavaScript-кода.

var pacman3d = function(canvasId) {
    this.init(canvasId); 
};
pacman3d.prototype = {
    antialias: true,
    showFps: true,
    showWorldAxis: true,
    fpsContainer: null,
    hudContainer: null,
    levelsContainer: null,
    scoreContainer: null,
    engine: null,
    scene: null,
    player: null,
    level: null,
    spriteManager: null,
    currentLevel: 0,
    levelsCompleted: 0,
    score: 0,
    mute: false,
};
pacman3d.prototype.init = function(canvasId) {
    var canvas = document.getElementById(canvasId);
    this.engine = new BABYLON.Engine(canvas, this.antialias);

    this.scene = new BABYLON.Scene(this.engine);
    this.scene.debugLayer.show();
    
    var camera = new BABYLON.FreeCamera('camera', new BABYLON.Vector3(5, 10, -8), this.scene);
    camera.attachControl(this.engine.getRenderingCanvas());
    this.scene.activeCamera = camera;
    
    var light  = new BABYLON.HemisphericLight('light', new BABYLON.Vector3(0, 1, 0), this.scene);
    light.intensity = 0.7;
    if (this.showFps) {
        this.fpsContainer = document.createElement('div');
        this.fpsContainer.title = this.fpsContainer.id = 'stats';
        document.body.appendChild(this.fpsContainer);
    }
    var $this = this;
    window.addEventListener('resize', function(){
        $this.engine.resize();
    });
    
    this.initSounds();
    this.initHud();
    this.run();
};
pacman3d.prototype.initHud = function() {
    this.hudContainer = document.createElement('div');
    this.hudContainer.id = 'hud';
    this.scoreContainer = document.createElement('span');
    this.scoreContainer.id = 'hud-score';
    this.levelsContainer = document.createElement('span');
    this.levelsContainer.id = 'hud-levels';
    this.hudContainer.appendChild(this.scoreContainer);
    this.hudContainer.appendChild(this.levelsContainer);
    this.scoreContainer.textContent = '0';
    this.levelsContainer.textContent = '0';
    document.body.appendChild(this.hudContainer);
};
pacman3d.prototype.updateHud = function() {
    this.scoreContainer.textContent = this.score;
    this.levelsContainer.textContent = this.levelsCompleted;
};
pacman3d.prototype.initSounds = function() {
    // ...
};
pacman3d.prototype.playSound = function(name) {
    // ...
};
pacman3d.prototype.createWorldAxis = function() {
    // ...
};
pacman3d.prototype.run = function() {
    var $this = this;
    if (this.showWorldAxis) this.createWorldAxis();
    this.engine.runRenderLoop(function() {
        $this.scene.render();
        if ($this.showFps) {
            $this.fpsContainer.innerHTML = $this.engine.getFps().toFixed() + ' fps';
        }
    });
};
pacman3d.prototype.checkCollisions = function() {
    // ...
};
pacman3d.prototype.nextLevel = function() {
    // ...
};

window.addEventListener('DOMContentLoaded', function() {
    window.game = new pacman3d('gameCanvas');
}, false);

Все пустые методы мы заполним позже. Итак, после загрузки страницы, создается главный объект игры и конструктору передается id элемента canvas.
Использование BabylonJS начинается с создания экземпляра класса BABYLON.Engine.

var canvas = document.getElementById(canvasId);
this.engine = new BABYLON.Engine(canvas, this.antialias);

Engine можно назвать сердцем или двигателем фреймворка. Первым аргументом при создании экземпляра BABYLON.Engine передается элемент canvas. Второй аргумент включает / отключает поддержку сглаживания (antialias).

Обязательным элементом является сцена. Приложение может иметь несколько сцен. Для создания сцены используется класс BABYLON.Scene, которому нужно передать объект BABYLON.Engine.

this.scene = new BABYLON.Scene(this.engine);

Сцена имеет такие часто используемые свойства, как clearColor - цвет фона, meshes - список мешей, lights - список источников света, materials - список материалов, textures - список текстур, cameras - список камер, activeCamera - активная камера.

На этапе разработки, особенно для улучшения производительности приложения, очень полезной может оказаться панель отладки. Ее можно включить следующим образом:

this.scene.debugLayer.show()

Панель отладки включает много информации, напр. fps, количество вызовов рендеринга сцены (draw calls), дерево мешей.
Несмотря на то, что debugLayer показывает fps, в коде я включил вызов метода getFps у BABYLON.Engine, т.к. это бывает часто нужно в самом приложении.

Следующий обязательный элемент - это камера. С помощью камеры мы “видим” сцену и все ее объекты. В коде выше используется камера BABYLON.FreeCamera. Это стандартная камера для шутеров от первого лица. Ею удобно пользоваться при разработке, т.к. она позволяет свободно оглянуть всю сцену с любой позиции.
Все камеры BabylonJS наследуют класс BABYLON.Camera. Этот класс можно наследовать для создания своих кастомных камер, если вас не устроят уже готовые.
Все камеры и многие другие конструкторы принимают первым параметром имя, а также принимают экземпляр сцены.
Класс BABYLON.Scene в свою очередь имеет метод getCameraByName.
Аналогичные методы имеются и для других объектов сцены: getMeshByName, getLightByName, getMaterialByName.
Камеры можно переключать, изменяя свойство сцены activeCamera. Второй аргумент BABYLON.FreeCamera, как можно догадаться - позиция камеры.

Для указания позиций используется класс BABYLON.Vector3.

Для управления камерой, нужно вызвать метод attachControl:

camera.attachControl(this.engine.getRenderingCanvas());

Следующий код важен для сохранения пропорций и разрешения картинки при изменении размеров браузера

var $this = this;
window.addEventListener('resize', function(){
    $this.engine.resize();
});

В методе initSounds будут загружаться используемые звуковые файлы.
В методе initHud создаются html-элементы для отображения номера текущего уровня игры и количества очков. Как вариант, HUD можно было реализовать как часть WebGL-приложения.

В методе run происходит необходимый вызов метода runRenderLoop. Этому методу передается функция, которая будет вызываться каждые 60 фреймов в секунду (в идеальном случае). Кстати, замечу, что на данный момент рендеринг в WebGL ограничен числом 60 fps.
Движок прорисовывает сцену при вызове render:

$this.scene.render();

Пустая сцена готова и показывается стандартный цвет фона, если он не был переопределен с помощью свойства clearColor, например таким образом

this.scene.clearColor = new BABYLON.Color3(0, 0, 0);

То есть, класс BABYLON.Color3 используются для задания цвета и это еще один класс, без которого вы навряд ли обойдетесь.

Навряд ли вы обойдетесь и без такой важной составляющей, как свет, хотя это необязательный элемент и приложение будет работать и без него.
BabylonJS имеет 4 типа источников света:
PointLight - точечный источник света, испускаемого во всех направлениях,
DirectionalLight - определяется направлением,
SpotLight - источник направленного света конической формы (например, фонарик),
HemisphericLight - симулирует свет солнца.

HemisphericLight определяется направлением к солнцу, диффузным цветом (diffuse, цвет пикселей, направленных вверх), цветом земли (groundColor, цвет пикселей, направленных к земле) и отраженным цветом (specular).

Свойства diffuse и specular, а также intensity относятся ко всем классам источников света. Каждый свет можно отключить / включить, вызвав метод setEnabled(true/false).

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

Mesh - еще одно базовое понятие 3d-программ. В BabylonJS они создаются статическими методами класса BABYLON.Mesh.
Вот некоторые из них:
BABYLON.Mesh.CreateBox - создание куба,
BABYLON.Mesh.CreateSphere - создание сферы,
BABYLON.Mesh.CreatePlane - создает плоскую прямоугольную поверхность,
BABYLON.Mesh.CreateCylinder - цилиндр,
BABYLON.Mesh.CreateTorus - создание тора,
BABYLON.Mesh.CreateTube - создание поверхности трубчатой формы,
BABYLON.Mesh.CreateRibbon - поверхность ленточной формы,
BABYLON.Mesh.CreateLines - создание линий.

Отмечу также возможность в BabylonJS загрузки подготовленных моделей из файлов.

Заполним кодом метод createWorldAxis

pacman3d.prototype.createWorldAxis = function() {
    var halfSize = 50;
    var x = BABYLON.Mesh.CreateLines('x', [
        new BABYLON.Vector3(-halfSize, 0, 0),
        new BABYLON.Vector3(halfSize, 0, 0),
        new BABYLON.Vector3(halfSize, 10, 0)
    ], this.scene);
    x.color = new BABYLON.Color3(1, 0, 0);
    var y = BABYLON.Mesh.CreateLines('y', [
       new BABYLON.Vector3(0, -halfSize, 0),
       new BABYLON.Vector3(0, halfSize, 0),
       new BABYLON.Vector3(10, halfSize, 0)
    ], this.scene);
    y.color = new BABYLON.Color3(0, 1, 0);
    var z = BABYLON.Mesh.CreateLines('z', [
       new BABYLON.Vector3(0, 0, -halfSize),
       new BABYLON.Vector3(0, 0, halfSize),
       new BABYLON.Vector3(0, 10, halfSize)
    ], this.scene);
    z.color = new BABYLON.Color3(0, 0, 1);
};

после чего можно будет увидеть следующий результат.

Самыми важными свойствами мешей являются position и rotation.
position - свойство-объект, которое содержит координаты меша на сцене.
rotation - свойство-объект, которое содержит информацию о ориентации меша относительно осей x, y и z. C помощью rotation меш можно вращать.
Оба свойства являются экземплярами объекта BABYLON.Vector3.

Создание уровня

Вся информация по уровням игры содержится в файле levels.js. Каждый уровень представлен в виде массива, который представляет из себя карту располагаемых объектов. Каждый элемент карты уровня - это обозначение типа блока данного участка карты.

Уровни создаются классом Level в методе Level.Create на основе карты уровня.

Level.Create = function(matrix, game) {
    var level = new Level(game);
    for (var z = 0; z < matrix.length; z++) {
        for (var x = 0; x < matrix[z].length; x++) {
            var type = matrix[z][x];
            if (type == Block.TYPES.NOTHING) {
                continue;
            }
            
            var position = new BABYLON.Vector3(x, 0, -z);
            var block = Block.create(game, position);
            level.blocks.push(block);
            
            if (type == Block.TYPES.NORMAL) {
                continue;
            }
            
            if (type == Block.TYPES.START) {
                level.startPosition = position;
                continue;
            }
            
            position.y = 0.9;
            
            if (type == Block.TYPES.COINX || type == Block.TYPES.COINZ) {
                var coin = Coin.create(game, position, type);
                level.coins.push(coin);
            } else if (type == Block.TYPES.ENEMY1) {
                var enemy = Ghost.create(game, z, x, position.y);
                level.enemies.push(enemy);
            } else if (type == Block.TYPES.ENEMY2) {
                
            }
        }
    }
    return level;
};

Конструктор класса Level:

var Level = function(game) {
    this.game = game;
    
    this.startPosition = new BABYLON.Vector3(0, 0, 0);
    this.score = 0;
    this.coins = [];
    this.blocks = [];
    this.enemies = [];
};

Классы GameObject и Block

Все объекты уровня основываются на классе GameObject, который в свою очередь наследуется от класса BABYLON.Mesh.

var GameObject = function(name, game) {
    BABYLON.Mesh.call(this, name, game.scene);
    this.game = game;
};
GameObject.prototype = Object.create(BABYLON.Mesh.prototype);
GameObject.prototype.constructor = GameObject;

Другой подход - не расширять класс BABYLON.Mesh, а инкапсулировать соответствующий экземпляр BABYLON.Mesh внутри игрового объекта.

Рассмотрим класс Block.

var Block = function(game, position) {
    GameObject.call(this, 'block', game);
    var vertexData = BABYLON.VertexData.CreateBox({size: 1});
    vertexData.applyToMesh(this);
    this.init(game, position);
};

Block.prototype = Object.create(GameObject.prototype);
Block.prototype.constructor = Block;

Block.TYPES = {
    NOTHING: '-',
    NORMAL: 0,
    START: 'S',
    COINX: 'CX',
    COINZ: 'CZ',
    ENEMY1: 'E1',
    ENEMY2: 'E2',
};

Block.prototype.init = function(game, position) {
    this.game = game;
    this.position.x = position.x;
    this.position.y = position.y;
    this.position.z = position.z;
};

Block.objectPrototype = null;

Block.create = function(game, position) {
    if (!Block.objectPrototype) {
        Block.objectPrototype = new Block(game, new BABYLON.Vector3(0, 0, 0));
        Block.objectPrototype.isVisible = false;
        Block.objectPrototype.setEnabled(false);
    }
    var block = Block.objectPrototype.createInstance('block');
    block.init = Block.prototype.init;
    
    block.isVisible = true;
    block.setEnabled(true);
    
    block.init(game, position);
    
    return block;
};

В Block.TYPES находятся все типы блоков.
Хочу здесь обратить внимание на два момента.
В конструкторе Block приходится создавать набор вершин, представляющий собой куб, с помощью BABYLON.VertexData.CreateBox, так как Block расширяет класс GameObject и, следовательно, BABYLON.Mesh. Потом этот набор применяется следующим образом:

vertexData.applyToMesh(this);

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

Block.objectPrototype = new Block(game, new BABYLON.Vector3(0, 0, 0));
Block.objectPrototype.isVisible = false;
Block.objectPrototype.setEnabled(false);

Улучшение производительности с помощью инстанцирования

В BabylonJS есть удобные средства для оптимизации. В данном приложении мы повсюду используем инстанцирование. Это позволяет отрендерить множество однотипных объектов с помощью одного “draw call”. Уменьшение количества необходимых вызовов прорисовки - эффективное средство для улучшения производительности. Если количество fps вас не устраивает, в первую очередь обратите внимание на значение “draw calls” в панели отладки. Пример инстанцирования блоков:

var block = Block.objectPrototype.createInstance('block');

С этой целью также можно использовать способ склеивания мешей. В BabylonJS это делается очень просто: в метод BABYLON.Mesh.MergeMeshes первым параметром нужно передать массив мешей. На выходе получится один единственный меш, поэтому применять этот способ имеет смысл только тогда, когда вам не нужны больше исходные меши как отдельные объекты, например, если не нужно изменять их позиции. Второй параметр метода BABYLON.Mesh.MergeMeshes позволяет оставить исходные объекты, если указать его равным false (по умолчанию равен true).

Упомяну еще, что BabylonJS поддерживает LOD (Level Of Detail). LOD - это способ улучшения производительности за счет уменьшения уровня детализации объекта при удалении от него.

Класс Coin

Для создания монеток используется BABYLON.VertexData.CreateCylinder.

var Coin = function(game, position, faceTo) {
    GameObject.call(this, 'coin', game);
    var vertexData = BABYLON.VertexData.CreateCylinder({
        height: 0.05,
        diameterBottom: 0.6,
        diameterTop: 0.6,
        tessellation: 16
    });
    vertexData.applyToMesh(this);
    this.init(game, position, faceTo);
};

Отличие монеток CX и CZ состоит в их ориентации. CX “смотрит” на плоскость yz, а CZ - на xy.

Coin.prototype.init = function(game, position, faceTo) {
    this.game = game;
    
    this.position.x = position.x;
    this.position.y = position.y;
    this.position.z = position.z;
    
    if (faceTo === Block.TYPES.COINZ) {
        this.rotation.x = Math.PI / 2;
    } else {
        this.rotation.z = Math.PI / 2;
    }
    this.animate();
};

rotation.x и rotation.z используются для разворота монеты. Углы задаются в радианах.

Класс Player

В классе Player используется метод CSG для задания геометрии. CSG расшифровывается как Constructive Solid Geometry и это способ моделирования геометрических тел с помощью комбинирования нескольких примитивов.
Мы используем сферу как основу, из которой вырезается треугольная призма, созданная с помощью BABYLON.Mesh.CreateCylinder.

var Player = function(game, position) {
    GameObject.call(this, 'player', game);
    
    var mouth = BABYLON.Mesh.CreateCylinder('playerMouth', 0.8, 0.8, 0.8, 3, 1, game.scene, false);
    var head = BABYLON.Mesh.CreateSphere('playerHead', 16, 0.8, game.scene);
    mouth.position.x += 0.4;
    mouth.rotation.y = Math.PI;
    mouth.rotation.x = Math.PI / 2;
    var mouthCSG = BABYLON.CSG.FromMesh(mouth);
    var headCSG = BABYLON.CSG.FromMesh(head);
    var playerCSG = headCSG.subtract(mouthCSG);
    mouth.dispose();
    head.dispose();
    
    var tmpPlayerMesh = playerCSG.toMesh('tmp', new BABYLON.StandardMaterial('tmp', game.scene), game.scene);
    var vertexData = BABYLON.VertexData.ExtractFromMesh(tmpPlayerMesh);
    vertexData.applyToMesh(this);
    tmpPlayerMesh.dispose();
    
    this.reset(position);
};

Анимация

Применим анимацию к монеткам.
Можно управлять анимацией самому, например используя свойства мешей position и rotation, а можно использовать заготовленный для этих целей класс BABYLON.Animation.

Для начала создадим объект BABYLON.Animation, указав свойство, которое будет изменяться

Coin.animation = new BABYLON.Animation(
    'coin', 'rotation.y', 30,
    BABYLON.Animation.ANIMATIONTYPE_FLOAT, BABYLON.Animation.ANIMATIONLOOPMODE_CYCLE
);

Третий параметр - количество фреймов в секунду для анимации,
четвертый - тип данных, пятый параметр позволяет зациклить анимацию, поэтому здесь указано BABYLON.Animation.ANIMATIONLOOPMODE_CYCLE.

Следующим шагом нужно создать набор значений для каждого фрейма (animation keys) и добавить его в свойство animations меша:

Coin.animation.setKeys([
    {
        frame: 0,
        value: 0
    },
    {
        frame: 15,
        value: Math.PI / 2
    },     
    {
        frame: 30,
        value: 0
    },     
    {
        frame: 45,
        value: -Math.PI / 2
    }, 
    {
        frame: 60,
        value: 0
    }
]);

И, наконец, с помощью метода beginAnimation сцены, анимация начинает работать:

Coin.prototype.animate = function() {
    this.animations.push(Coin.animation.clone());
    this.getScene().beginAnimation(this, 0, 60, true, 1.0);
};

Четвертый параметр метода beginAnimation установлен в true для зацикливания анимации.

Если вы управляете анимацией вручную, обратите внимание на метод сцены getAnimationRatio. Использование этого метода позволяет сделать скорость анимации независимой от fps игры.

Что получается на данный момент:
демо,
html,
код класса игры,
остальной код.

Физический движок

BabylonJS имеет хорошую интеграцию с двумя физическими движками: Oimo.js и Cannon.js. Мы будем использовать последний.

Чтобы использовать физический движок в BabylonJS всего лишь нужно подключить файл с кодом движка и вызвать метод enablePhysics. Добавим этот вызов в начало метода run:

this.scene.enablePhysics();

enablePhysics принимает два аргумента. Первый - это gravity с характиристиками притяжения в виде вектора BABYLON.Vector3. По умолчанию gravity равняется BABYLON.Vector3(0, -9.807, 0).
Второй параметр позволяет нам указать конкретный физический движок. В версии BabylonJS 2.4 используется по умолчанию Cannon.js и этот параметр равен new BABYLON.CannonJSPlugin(). Чтобы использовать Oimo.js нужно передать new BABYLON.OimoJSPlugin().

После включения физического движка пока ничего видимого не произойдет. Pacman остается висеть и не падает на платформу. Чтобы применить физику к мешу, ему нужно добавить так называемое “rigid body” (в переводе - твердое тело). Каждое “rigid body” имеет какую-то форму. Чем проще форма (по другому - impostor), тем легче движку выполнять свою работу. Коллизии рассчитываются с учетом выбранной формы, то есть они не применяются непосредственно к мешу.
Некоторые виды “импостеров”:
BABYLON.PhysicsImpostor.SphereImpostor, BABYLON.PhysicsImpostor.BoxImpostor, BABYLON.PhysicsImpostor.PlaneImpostor, BABYLON.PhysicsImpostor.CylinderImpostor, BABYLON.PhysicsImpostor.HeightmapImpostor.

Добавим к мешу игрока “rigid body”:

this.physicsImpostor = new BABYLON.PhysicsImpostor(
    this, 
    BABYLON.PhysicsImpostor.SphereImpostor, 
    { mass: 0, restitution: 0.5, friction: 0.1 },
    game.scene
);

Как видите, нужно создать экземпляр BABYLON.PhysicsImpostor и присвоить его свойству меша physicsImpostor. Конструктор PhysicsImpostor должен получить первым параметром сам меш, вторым - вид тела для расчета физики, третьим - миксин с физическими характеристиками, четвертым, что привычно при работе с BabylonJS - объект сцены.

Смысл физических характеристик, которые мы указываем, ясен из названий: mass - масса тела (в кг), friction - коэффициент трения при соприкосновении с другими “rigid body”, restitution - коэффициент сопротивления (попробуйте увеличить это число и вы увидите, что увеличилась “прыгучесть”).

Сейчас pacman все еще висит в воздухе. Это потому что мы указали нулевую массу. После

this.physicsImpostor.setMass(1);

он начнет падать.

Есть несколько способов как перемещать или вращать меш с применением физического движка.
Чтобы переместить меш, можно использовать метод “импостера” setLinearVelocity, например

this.physicsImpostor.setLinearVelocity(new BABYLON.Vector3(0, 1, 0));

Конечно, это не заставит тело двигаться с постоянной скоростью вверх, т.к. на него действует сила притяжения.

Для вращения существует аналогичный метод setAngularVelocity. Пример:

this.physicsImpostor.setAngularVelocity(new BABYLON.Quaternion(0, 1, 0, 0));

Единственным аргументом метода является объект BABYLON.Quaternion.
Теперь, кстати, для вращения “PC” мы будем использовать кватернионы.

Другим способом изменить положение тела является применение силы или импульса. Чтобы применить импульс, нужно использовать метод applyImpulse, например:

this.physicsImpostor.applyImpulse(new BABYLON.Vector3(0, 10, 0), this.getAbsolutePosition());

Первый параметр - вектор импульса, второй - место приложения вектора импульса.

Еще одним объектом, к которому нужно применить физику, является платформа. Иначе, pacman падает вниз сквозь нее.

this.physicsImpostor = new BABYLON.PhysicsImpostor(
    this,
    BABYLON.PhysicsImpostor.BoxImpostor,
    { mass: 0, restitution: 0.2, friction: 0.5 },
    game.scene
);

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

Настала пора добавить управление pacman-ом. Обозначу основные моменты.
Инициализацией управления занимается метод Player.prototype.initControls. Как было замечено выше, для вращения используется кватернион.
При нажатии на клавиши, информация о нажатых клавишах сохраняется в массивах directions и rotations объекта Player. Потом в каждом фрейме вызывается метод Player.prototype.move, который и изменяет позицию и вращение. Вызов метода move помещен в callback метода сцены registerBeforeRender:

this.scene.registerBeforeRender(function() {
    $this.player.move();
    if ($this.player.position.y < -20) {
        $this.level.reset();
    }
});

Если pacman сваливается с платформы уровень сбрасывается и возвращается на начальную позицию.

Заменим камеру FreeCamera на FollowCamera.

var camera = new BABYLON.FollowCamera('camera', new BABYLON.Vector3(5, 10, -8), this.scene);
camera.radius = 10;
camera.heightOffset = 6;
camera.rotationOffset = 180;

Эта камера будет следовать за pacman при его перемещениях. Но для этого нужно присвоить свойство target:

this.scene.activeCamera.target = this.player;

Промежуточные результаты:
демо,
html,
код класса игры,
остальной код.

Спрайтовое привидение

Создадим класс Ghost, который как и остальные объекты наследует класс GameObject. Но этот объект будет реализован на основе спрайтов.
Спрайты можно использовать не только в двумерной графике, но и в трехмерной для реализации различных эффектов, симуляции деревьев и т.п. Конечно же, BabylonJS можно использовать и для двумерных приложений и одним из вспомогательных средств в BabylonJS являются спрайты. В последних версиях появился небольшой 2d-движок.

Чтобы использовать спрайты в BabylonJS, нужно создать вспомогательный менеджер спрайтов. В разбираемом коде это делается в методе pacman3d.prototype.init перед вызовом run:

this.spriteManager = new BABYLON.SpriteManager('spriteManager', 'images/ghost.png', 10, 300, this.scene);

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

Привидение является мешом, а точнее кубом, но сам меш является невидимым, вместо этого к кубу привязываем спрайт таким образом (см. метод Ghost.prototype.init):

this.sprite = new BABYLON.Sprite('enemy', game.spriteManager);
this.sprite.position = this.position;
this.sprite.playAnimation(0, 6, true, 150);

Как видно, конструктору BABYLON.Sprite нужно передать, кроме имени, спрайтовый менеджер. Потом позиция спрайта привязывается к позиции меша с помощью присваивания свойства position. После вызова sprite.playAnimation начинает работать анимация спрайта, картинки входящие в спрайт начинают сменять друг друга, начиная с самой первой под номером 0.
Третий параметр со значением true зацикливает анимацию. Четвертый - характеризует скорость анимации (чем меньше, тем быстрее).

Чтобы привидение двигалось и правильно выбирало дорогу, не падая вниз, я наделил его зачатками AI (см. файл EnemyBrain.js):

this.ai = new EnemyBrain(this.game.currentLevel, mapCellZ, mapCellX);

Промежуточные результаты:
демо,
html,
код класса игры,
остальной код.

Обработка коллизий, подсчет монеток и логика смены уровней

BabylonJS предоставляет встроенные средства для проверки коллизий. Каждый меш имеет свойство showBoundingBox. Если его установить в true, вы увидите ограничивающий куб вокруг меша. Именно он и используется для расчета коллизий.

Проверка коллизий в нашем приложении запускается в каждом фрейме следующим кодом в pacman3d.prototype.run:

this.scene.registerAfterRender(function() {
    $this.checkCollisions();
});

Для проверки коллизий между мешами у каждого меша есть метод intersectsMesh. В этот метод нужно передать другой меш, коллизиции с которым проверяются. Например, в следующем коде проверяются коллизии с мешами-привидениями:

Level.prototype.checkEnemyCollisions = function() {
    var enemy;
    for (var i = 0; i < this.enemies.length; i++) {
        enemy = this.enemies[i];
        if (enemy.intersectsMesh(this.game.player)) {
            return true;
        }
    }
    return false;
};

Если происходит коллизия “pacman и привидение”, уровень сбрасывается и игрок возвращается на первоначальную позицию.
Если происходит коллизия “pacman и монетка”, увеличивается счетчик собранных монеток и, если монеток не осталось, идет переход на следующий уровень. Текущий уровень при этом удаляется вызовом this.level.eliminate();, а новый создается.
Обработка коллизий в главном классе игры:

pacman3d.prototype.checkCollisions = function() {
    var eaten = this.level.checkEnemyCollisions();
    if (eaten) {
        this.level.reset();
        return;
    }
    this.level.checkCoinCollisions();
    if (this.level.isCompleted()) {
        this.nextLevel();
    }
};

Кроме пересечения с другим мешом, можно проверить коллизию с точкой в пространстве (BABYLON.Vector3). Для этого у меша есть метод intersectsPoint. Еще один схожий метод, а именно intersects проверяет коллизию с лучом (см. класс BABYLON.Ray).
intersects возвращает немного интересной информации о пересечении, например точку пересечения (pickedPoint) и расстояние между ней и началом луча (distance).

Еще один вид коллизий, который наверняка пригодится - это выбор объектов мышкой. Объект сцены имеет метод pick, которому нужно передать координаты указателя, например:

var pickInfo = this.scene.pick(this.scene.pointerX, this.scene.pointerY);

В этом примере, pickInfo содержит схожую с результатом intersects информацию, включая pickedMesh (меш, который выбран мышкой).

Функционал уже реализован, осталось приукрасить графику и добавить звуки:
демо,
html,
код класса игры,
остальной код.

Материал и текстуры

Материал и текстуры - еще одни важнейшие элементы 3d-приложений, особенно игровых.
Материал характеризует оптические свойства объектов. С помощью текстур мы можем имитировать поверхность реальных предметов.

На данный момент все объекты, кроме привидений и осей координат раскрашены дефолтным серым цветом. Сейчас мы все раскрасим!

Начнем с главного игрового персонажа.

var material = new BABYLON.StandardMaterial('player', game.scene);

BABYLON.StandardMaterial является стандартным материалом BabylonJS. Конструктору этого класса нужно передать название материала и конкретную сцену. В дальнейшем этот материал можно достать с помощью метода getMaterialByName сцены. Все остальное достигается путем использования соответствующих свойств материала.

material.diffuseColor = material.ambientColor = new BABYLON.Color3(1, 1, 0);

Материал применяется к объекту очень просто

this.material = material;

После этих действий pacman становится желтым.

Раскрашиваем монетки

var material = new BABYLON.StandardMaterial('coin', game.scene);
material.diffuseColor = new BABYLON.Color3(1,1,1);
material.emissiveColor = new BABYLON.Color3(1,1,1);

Итак, о некоторых свойства материалов.
diffuseColor - цвет объекта под светом или диффузный цвет (то есть на финальный цвет влияет цвет света),
emissiveColor - цвет объекта без источника света (то есть его продуцирует сам объект) или исходящий свет,
ambientColor - фоновый цвет объекта, т.е. цвет, на который влияет фоновый цвет сцены (свойство scene.ambientColor). Фоновый свет - свет, который распределен или рассеян средой.
specularColor - зеркальный цвет, цвет отражения на поверхности объекта.
Свойство specularPower характеризует отражающие свойства материала.
alpha - прозрачность материала.
Свойство wireframe, установленное в true показывает каркас меша.
С точки зрения производительности полезно выключить отображение текстур с обратной стороны поверхностей путем присвоения false свойству backFaceCulling.

Следующий код, демонстрирует некоторые дополнительные эффекты, которые можно реализовать в BabylonJS достаточно просто.

material.emissiveFresnelParameters = new BABYLON.FresnelParameters();
material.emissiveFresnelParameters.bias = 0.01;
material.emissiveFresnelParameters.power = 2;
material.emissiveFresnelParameters.leftColor = BABYLON.Color3.Black();
material.emissiveFresnelParameters.rightColor = new BABYLON.Color3(1, 1, 1);

После этого монетки стали блестеть.

Что касается платформы, то для нее мы добавим текстуры

this.material = new BABYLON.StandardMaterial('ground', game.scene);
this.material.diffuseTexture = new BABYLON.Texture('images/ground1.jpg', game.scene);

Конструктору BABYLON.Texture нужно передать путь к изображению текстуры и объект сцены, после чего присвоить полученную текстуру свойству diffuseTexture материала.
Кстати, для свойств материала diffuseColor, emissiveColor, ambientColor и specularColor существуют аналогичные текстурные свойства с аналогичными различиями между ними: diffuseTexture, emissiveTexture, ambientTexture и specularTexture.

Из свойств текстур хочу обратить внимание на следующие:
hasAlpha - установите в true если текстура содержит alpha-канал,
uOffset и vOffset - смещение в системе координат (u, v) текстуры,
uScale и vScale - скейлинг текстуры вдоль осей u и v.

Это конечно же не все возможности, которые предоставляет BabylonJS с помощью текстур. Например, поддерживаются видеотекстуры (BABYLON.VideoTexture), имитация зеркал с помощью BABYLON.MirrorTexture, бамп маппинг (см. свойство материала bumpTexture).

В нашей программе используется skybox с помощью кубической текстуры для создания окружения:

var skybox = BABYLON.Mesh.CreateBox('skybox', 100.0, this.scene);
skybox.infiniteDistance = false;
var skyboxMaterial = new BABYLON.StandardMaterial('skybox', this.scene);
skyboxMaterial.backFaceCulling = false;
skyboxMaterial.disableLighting = true;
skyboxMaterial.reflectionTexture = new BABYLON.CubeTexture('images/skybox/env', this.scene);
skyboxMaterial.reflectionTexture.coordinatesMode = BABYLON.Texture.SKYBOX_MODE;
skyboxMaterial.diffuseColor = new BABYLON.Color3(0, 0, 0);
skyboxMaterial.specularColor = new BABYLON.Color3(0, 0, 0);
skybox.material = skyboxMaterial;

Звук

В заключение обзора добавим звуки к игре. Ниже пример как инициализируется звуковой объект (см. метод pacman3d.prototype.initSounds):

new BABYLON.Sound('coin', 'audio/coin.ogg', this.scene, function() {
    // console.log('coin sound is ready');
});

Инициализация звука не выделяется из подхода, прослеживаемого по всему фреймворку. В конструктор передаются: имя, путь к файлу, объект сцены. Следующий параметр позволяет выполнить код после успешной загрузки файла. Последним параметром можно указать дополнительные опции, например зациклить воспроизведение.

В нужных участках кода, когда нужно воспроизвести звуковой файл вызывается следующий метод:

pacman3d.prototype.playSound = function(name) {
    if (!this.mute) {
        this.scene.getSoundByName(name).play();
    }
};

Конечный результат:
демо,
исходный код.



Введение в WebGL

2016-07-29 08:00:10 (читать в оригинале)

В данном обзоре мы создадим простую 3d-программу, используя WebGL. Сначала мы будем использовать WebGL Api напрямую. Потом сделаем варианты на популярных WebGL-фреймворках Three.js и BabylonJS.
Я не привожу в тексте полные листинги примеров, все они доступны на github по этой ссылке.

Небольшое вступление

WebGL позволяет использовать в браузере преимущества аппаратного ускорения трехмерной графики без установки плагинов. WebGL основана на OpenGL ES 2.0, которая в свою очередь базируется на спецификации OpenGL 2.0 и используется на мобильных устройствах. Название “WebGL” можно интерпретировать как “OpenGL для браузеров”.

В состав рабочей группы WebGL, разрабатывающей стандарт, входит некоммерческая организация Khronos Group, а также разработчики ведущих браузеров.

Первая версия WebGL была выпущена в 2011 году. На данный момент последней является версия 1.0.3, выпущенная в 2014, и ожидается выход версии 2.0. Версия 2.0 основана уже на OpenGL ES 3.0 API.

Все популярные браузеры (Safari, Chrome, Firefox, IE, Edge) поддерживают WebGL, в том числе и на мобильных устройствах. Позже всех включили поддержку WebGL в своих браузерах Apple и Microsoft: Apple - начиная с Safari 8 в 2014 году, Microsoft - начиная с IE 11 в 2013 году.

WebGL используется не только для создания 3d-программ. Многие 2d-фреймворки используют WebGL, получая все преимущества аппаратного ускорения.

Используем WebGL Api напрямую

Даже если вы используете WebGL-фреймворк и не собираетесь участвовать в разработке оного, знания по WebGL необходимы для понимания того, что происходит в вашей программе, для решения возникающих задач, в том числе проблем производительности. И Three.js и BabylonJS предоставляют api низкого уровня, которое близко к использованию нативного api. Иногда возникает потребность переписать / дописать часть кода фреймворка специально для своего приложения.

Долгое время на официальном сайте Three.js можно было прочитать, что для рисования куба, используя только нативные средства браузера, понадобилось бы написать сотни строк кода. three.js documentation screenshot

На самом деле нам действительно понадобится больше чем сотня строк, но все не так сложно как то там звучит.

Шаг первый

Сначала создадим html. Нам нужен элемент canvas и его контекст “webgl”. Для поддержки устаревших версий браузеров, в частности Internet Explorer 11, нужно проверить контекст “experimental-webgl”.
HTML:

<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Cube - native WebGL</title>
<style>
html, body {
    overflow: hidden;
    width: 100%;
    height: 100%;
    margin: 0;
    padding: 0;
}
#appCanvas {
    width: 400px;
    height: 400px;
    touch-action: none;
}
</style>

<script src="./cube-native-step1.js"></script>

</head>
<body>
    <canvas id="appCanvas" oncontextmenu="return false;">Browser not supported</canvas>
</body>
</html>

JavaScript:

var Cube = function(canvasId) {
    this.canvas = document.getElementById(canvasId);
    var params = {antialias: true};
    this.gl = this.canvas.getContext('webgl', params) || this.canvas.getContext('experimental-webgl', params);
    if (!this.gl) {
        document.body.innerHTML = 'Unfortunately your browser is not supported';
        return;
    }
};
window.addEventListener('DOMContentLoaded', function() {
    window.cube = new Cube('appCanvas');
});

Чтобы что-то нарисовать в WebGL, как и в любой программе OpenGL, необходимы шейдеры. По сути, шейдеры - это программы на C-подобном языке, которые выполняются графической картой. Язык, используемый в шейдерах (shading language), ограничен и специально разработан для решения типичных графических задач, например матричных/векторных операций. В WebGL используется язык шейдеров OpenGL ES SL. Есть два типа шейдеров: вершинный (vertex shader) и фрагментный (fragment shader). Вершинный используется в основном для описания геометрии. Он выполняется для каждой вершины, которую передали шейдеру.

Фрагментный шейдер выполняется для каждого фрагмента изображения (своего рода “пикселя”). Часть данных фрагментный шейдер получает от вершинного шейдера и эти данные интерполируются. В основном он используется для применения освещения, текстур. Его задача - определить цвет каждого фрагмента.

Как видно из кода ниже, цвет фрагмента определяется путем присвоения значения специальной переменной gl_FragColor. В вершинном шейдере нужно присвоить значение специальной переменной gl_Position для задания координаты вершины. При этом используются данные, которые передаются из javascript-части, т.е. в нашем случае - это переменная-атрибут aPosition. Переменные gl_FragColor и gl_Position имеют специальное предназначение и их не нужно объявлять самому.

Вершинный шейдер, записанный в виде массива:

vxShader: [
    'attribute vec3 aPosition;',
    'void main(void) {',
        'gl_Position = vec4(aPosition, 1.0);',
   '}'
],

Фрагментный шейдер в виде массива:

fgShader: [
    'void main(void) {',
        'gl_FragColor = vec4(0.3, 0.3, 0.7, 1.0);',
    '}'
],

Т.к. шейдеры мы задали массивом, получим код вершинного шейдера таким образом

var shaderSrc = this.vxShader.join('\n');

Рассмотрим код функции createShader, которая создает и компилирует шейдер. Да, компилирует, а потом нас ждет и линкование.

createShader: function(src, type) {
    var shader = this.gl.createShader(type);
    this.gl.shaderSource(shader, src);
    this.gl.compileShader(shader);
    var compiled = this.gl.getShaderParameter(shader, this.gl.COMPILE_STATUS);
    if (!compiled) {
        console.log(this.gl.getShaderInfoLog(shader));
        this.gl.deleteShader(shader);
        return null;
    }
    return shader;
},

Создание шейдера с указанием типа

var shader = this.gl.createShader(type);

Указание кода шейдера

this.gl.shaderSource(shader, src);

Компиляция шейдера

this.gl.compileShader(shader);

После компиляции шейдера хорошо бы проверить статус компиляции и вывести информацию об ошибках, если таковые были.

var compiled = this.gl.getShaderParameter(shader, this.gl.COMPILE_STATUS);
if (!compiled) {
    this.debugError(this.gl.getShaderInfoLog(shader));
    this.gl.deleteShader(shader);
    return null;
}

Cозданный шейдер понадобится далее для создания объекта WebGLProgram в методе initProgram. Сначала метод initProgram вызывает createShader, чтобы создать оба шейдера.

initProgram: function() {
    var shaderSrc = this.vxShader.join('\n');
    var vxShader = this.createShader(shaderSrc, this.gl.VERTEX_SHADER);
    shaderSrc = this.fgShader.join('\n');
    var fgShader = this.createShader(shaderSrc, this.gl.FRAGMENT_SHADER);
    if (!vxShader || !fgShader) {
        return false;
    }
    ...

Потом создается программа WebGLProgram

var prg = this.gl.createProgram();

Указываем оба скомпилированных шейдера:

this.gl.attachShader(prg, vxShader);
this.gl.attachShader(prg, fgShader);

И, наконец, обещанное линкование:

this.gl.linkProgram(prg);

Хорошей практикой является проверка статуса линкования и логирование возможных ошибок.

var linked = this.gl.getProgramParameter(prg, this.gl.LINK_STATUS);
if (!linked) {
    console.log(this.gl.getProgramInfoLog(prg));
    this.gl.deleteProgram(prg);
    this.gl.deleteShader(fgShader);
    this.gl.deleteShader(vxShader);
    return false;
}

После линкования нужно “принять к использованию” созданную программу, вызвав метод контекста useProgram

this.prg = prg;
this.gl.useProgram(this.prg);

На этом этапе программа успешно создана. Остается только передать в шейдеры все нужные данные. Выглядит несколько громоздко, но зато гибко. Можно менять программы во время выполнения. В этом же методе я добавил код для получения ссылок на используемые шейдерами переменные. Получим ссылку на переменную aPosition таким образом

this.prg.aPosition = this.gl.getAttribLocation(this.prg, 'aPosition');

Т.е. ссылка будет храниться в this.prg.aPosition.

Приготовим куб, для чего создадим конструктор CubeMesh.

function CubeMesh(vertices, indices) {
    this.vertices = new Float32Array(vertices);
    this.indices = new Uint8Array(indices);
    this.elementsCnt = indices.length;
    this.vbo = this.ibo = null;
}

Этому объекту, как видно, нужно передать список vertices (вершины) и indices (индексы или номера вершин).

Удобно задавать геометрию в WebGL с помощью списка индексов. В WebGL есть несколько режимов рисования, один из них - это рисование треугольниками. Таким образом, чтобы нарисовать прямоугольник понадобится 2 треугольника. Чтобы нарисовать куб нужно 12 треугольников. Используя индексы, нам достаточно определить 8 вершин и передать графической системе координаты этих 8 вершин, т.е. массив из 24 чисел типа float для задания vertices и 12 наборов по три числа для передачи indices, т.е. дополнительно массив из 36 чисел типа integer. Каждый индекс указывает на соответствующую вершину в массиве вершин. Без индексов при использовании метода рисования gl.TRIANGLES понадобится 12 (кол-во треугольников) * 3 (три вершины) * 3 (3 координаты для каждой вершины) = 108 чисел типа float.

Начало системы координат находится в центре области рисования. Ось Y направлена вверх, ось X направлена вправо. Что касается оси Z, то тут можно выбирать на свой вкус. WebGL не навязывает ни правостороннюю ни левостороннюю систему, хотя так называемая усеченная система координат (clip coordinate system) является левосторонней. В усеченной системе, все точки выходящие за отрезок [-1.0, 1.0] удаляются. Все координаты в конце концов приводятся к усеченной системе координат. В Three.js система координат правосторонняя (ось Z направлена на наблюдателя от экрана), как принято в большинстве программ OpenGL, а в BabylonJS левосторонняя система (как долгое время было в DirectX).

createCube: function() {
    var halfSize = 0.5;
    var vertices = [
        halfSize, halfSize, halfSize, // 0
        -halfSize, halfSize, halfSize, // 1
        -halfSize, -halfSize, halfSize, // 2
        halfSize, -halfSize, halfSize, // 3
        halfSize, halfSize, -halfSize, // 4
        -halfSize, halfSize, -halfSize, // 5
        -halfSize, -halfSize, -halfSize, // 6
        halfSize, -halfSize, -halfSize // 7
    ];
    var indices = [
        0, 1, 2, 0, 2, 3, // front
        4, 7, 6, 4, 6, 5, // back
        0, 4, 5, 0, 5, 1, // up
        3, 2, 6, 3, 6, 7, // down
        3, 7, 4, 3, 4, 0, // right
        1, 5, 6, 1, 6, 2, // left
    ];
    this.cubeMesh = new CubeMesh(vertices, indices);
}

halfSize здесь это половина размера куба. Покаместь мы задали координаты в усеченной системе.

После задания геометрии, нужно создать буферы памяти и поместить в них заготовленные данные (см. метод initBuffers).

initBuffers: function() {
    this.cubeMesh.vbo = this.gl.createBuffer();
    this.cubeMesh.ibo = this.gl.createBuffer();
    this.gl.bindBuffer(this.gl.ARRAY_BUFFER, this.cubeMesh.vbo);
    this.gl.bufferData(this.gl.ARRAY_BUFFER, this.cubeMesh.vertices, this.gl.STATIC_DRAW);
    this.gl.bindBuffer(this.gl.ELEMENT_ARRAY_BUFFER, this.cubeMesh.ibo);
    this.gl.bufferData(this.gl.ELEMENT_ARRAY_BUFFER, this.cubeMesh.indices, this.gl.STATIC_DRAW);
    this.gl.bindBuffer(this.gl.ARRAY_BUFFER, null);
    this.gl.bindBuffer(this.gl.ELEMENT_ARRAY_BUFFER, null);
}

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

this.gl.clearColor(0.0, 0.0, 0.0, 1.0);
this.gl.clearDepth(1.0);
this.gl.enable(this.gl.DEPTH_TEST);

Окончательный вид конструктора:

var Cube = function(canvasId) {
    this.canvas = document.getElementById(canvasId);
    var params = {antialias: true};
    this.gl = this.canvas.getContext('webgl', params) || this.canvas.getContext('experimental-webgl', params);
    if (!this.gl) {
        document.body.innerHTML = 'Unfortunately your browser is not supported';
        return;
    }
    if (!this.initProgram()) {
        return;
    }
    this.createCube();
    this.initBuffers();
    
    this.gl.clearColor(0.0, 0.0, 0.0, 1.0);
    this.gl.clearDepth(1.0);
    this.gl.enable(this.gl.DEPTH_TEST);
    this.renderLoop();
};

В последней строчке конструктора идет вызов метода renderLoop, который вызывает this.render(). В методе render и происходит рисование. renderLoop вызывается в каждом фрейме с помощью requestAnimationFrame, также как и при использовании 2d контекста канваса.

Рассмотрим теперь метод render. В нем сначала очищается область рисования, а точнее буфер цвета и глубины.

this.gl.clear(this.gl.COLOR_BUFFER_BIT | this.gl.DEPTH_BUFFER_BIT);

Потом нужно активировать данные конкретного геометрического объекта. В нашем случае он один.

this.gl.enableVertexAttribArray(this.prg.aPosition);
this.gl.bindBuffer(this.gl.ARRAY_BUFFER, this.cubeMesh.vbo);
this.gl.vertexAttribPointer(this.prg.aPosition, 3, this.gl.FLOAT, false, 0, 0);
this.gl.bindBuffer(this.gl.ELEMENT_ARRAY_BUFFER, this.cubeMesh.ibo);

Если используются индексы, то рисование производится методом drawElements контекста

this.gl.drawElements(this.gl.TRIANGLES, this.cubeMesh.elementsCnt, this.gl.UNSIGNED_BYTE, 0);

На данном этапе нарисован красный куб, хотя мы видим квадрат из-за его расположения. Можно было подобрать другие значения координат вершин, чтобы куб был повернут по другому. Итак, имеем всего около 128 строк. Что получилось на данный момент - демо шаг 1.

Шаг второй - мировая система координат, вращающаяся камера

Перейдем к более удобной системе координат, она будет, кстати, правосторонняя. Для этого создадим объект Camera. Камера будет ответственна за направление взгляда на сцену и за определение области видимости (frustrum), т.е. той области пространства за пределами, которой точки будут отброшены. Это достигается путем матричных преобразований, поэтому нам понадобится вспомогательный объект Matrix. Для удобства введем также объект Vector.

Камера будет использовать перспективную проекцию, т.е. frustrum выглядит как усеченная пирамида. Конструктору будем передавать элемент canvas (используется для подсчета aspect ratio - отношения ширины кадра к высоте), угол обзора по оси y, расстояние до ближней и дальней плоскостей области видимости, а также позицию камеры в пространстве. Камера смотрит на начало координат. Реализованную камеру программно можно вращать по поверхности сферы с центром в начале координат, а также перемещать.

this.camera = new Camera(this.canvas, 0.8 /* ~45.84deg */, 1, 260, new Vector(-95, 95, 95));

Матрицу преобразования камеры нужно передать в вершинный шейдер. Эта матрица состоит из двух матриц: матрицы вида и матрицы проекции. В данной программе в шейдер передаются обе матрицы отдельно и уже там они умножаются. Теперь вершинный шейдер выглядит так:

vxShader: [
    'attribute vec3 aPosition;',
    'uniform mat4 uPMatrix;',
    'uniform mat4 uVMatrix;',
    'void main(void) {',
        'vec4 vertex = vec4(aPosition, 1.0);',
        'gl_Position = uPMatrix * uVMatrix * vertex;',
   '}'
]

В initProgram добавлены две строчки для получения ссылок на переменные:

this.prg.uPMatrix = this.gl.getUniformLocation(this.prg, 'uPMatrix');
this.prg.uVMatrix = this.gl.getUniformLocation(this.prg, 'uVMatrix');

Чтобы передать данные в шейдер, в методе render добавлены такие две строчки перед прорисовкой сцены:

this.gl.uniformMatrix4fv(this.prg.uPMatrix, false, this.camera.pMatrix.elements);
this.gl.uniformMatrix4fv(this.prg.uVMatrix, false, this.camera.vMatrix.elements);

Поскольку сейчас используется камера, размеры куба можно увеличить, иначе он будет слишком маленький. В css я внес некоторые изменения, теперь область рисования занимает всю клиентскую часть браузера. Чтобы изображение оставалось пропорциональным добавлен метод handleSize, который вызывается при ресайзе окна и вначале работы программы. Наконец, в renderLoop перед вызовом метода render, добавим вызов this.camera.update() с несколькими строчками, которые обеспечивают вращение камеры вокруг начала координат и куба.

Итак, вращающийся куб готов (на самом деле вращается камера) - демо шаг 2. Да, нам понадобилось почти три с половиной сотни строк, но выглядит это не как “страшные многие сотни”. Поэтому, считаю выражение в документации Three.js несколько преувеличенным. Заметно, что часть кода легко выносится отдельно для повторного использования: объект Matrix для операций с матрицами, Vector для операций с векторами, создание и компиляция шейдеров, функционал камеры.

Шаг третий - важность света

Добавим теперь простенькое освещение, которое придаст объем трехмерной сцене. Будем использовать точечный источник цвета. Освещение в нашем случае будет вычисляться в вершинном шейдере. Вычисления во фрагментном шейдере дают более реалистичное затенение. Таким образом, в вершинном шейдере вычислим цвет каждой вершины. Чтобы передавать данные между шейдерами нужно использовать varying-переменные. Цвет будет передаваться во фрагментный шейдер с помощью varying-переменной vColor. Значения цвета при этом интерполируются. В вершинный шейдер добавлен следующий участок кода:

'vec4 color = vec4(0.3, 0.3, 0.7, 1.0);',
'vec3 normal = vec3(uNMatrix * vec4(aNormal, 1.0));',
'vec4 lightPos = vec4(uLightPosition, 1.0);',
'vec3 lightRay = vertex.xyz - lightPos.xyz;',
'float lambertTerm = max(dot(normalize(normal), -normalize(lightRay)), 0.0);',
'vec3 diffuse = vec3(1.0, 1.0, 1.0) * color.rgb * lambertTerm;',
'vec3 ambient = vec3(0.5, 0.5, 0.5) * color.rgb;',
'vColor = vec4(ambient + diffuse, color.a);',

Как в вершинном, так и во фрагментном шейдере нужно объявить varying-переменную vColor. В вершинном шейдере понадобится еще uniform-переменная uLightPosition (позиция источника цвета), переменная-атрибут aNormal (нормаль к вычисляемой вершине) и матрица нормалей uNMatrix.

'uniform vec3 uLightPosition;',
'varying vec4 vColor;',
'attribute vec3 aNormal;',
'uniform mat4 uNMatrix;',

Во фрагментном шейдере все остается просто:

'precision mediump float;',
'varying vec4 vColor;',
'void main(void) {',
    'gl_FragColor = vColor;',
'}'

Получаем ссылки на переменные в initProgram:

this.prg.aNormal = this.gl.getAttribLocation(this.prg, 'aNormal');
this.prg.uNMatrix = this.gl.getUniformLocation(this.prg, 'uNMatrix');
this.prg.uLightPosition = this.gl.getUniformLocation(this.prg, 'uLightPosition');

В методе render передаем информацию о позиции источника света и матрицу нормалей:

this.gl.uniform3fv(this.prg.uLightPosition, this.light.position.toArray());
this.gl.uniformMatrix4fv(this.prg.uNMatrix, false, this.nMatrix.elements);

Поскольку куб не двигается, в нашем случае его матрица нормалей будет всегда единичной матрицей и ее можно не использовать. Иначе, матрицу нормалей нужно пересчитывать для каждого освещаемого объекта на сцене при каждом перемещении.

Для передачи данных о нормалях нужно создать буфер нормалей. Конструктор CubeMesh теперь принимает массив нормалей. В функции initBuffers добавлено создание буфера нормалей,

this.cubeMesh.nbo = this.gl.createBuffer();
this.gl.bindBuffer(this.gl.ARRAY_BUFFER, this.cubeMesh.nbo);
this.gl.bufferData(this.gl.ARRAY_BUFFER, this.cubeMesh.normals, this.gl.STATIC_DRAW);

а в функции render:

this.gl.enableVertexAttribArray(this.prg.aNormal);
this.gl.bindBuffer(this.gl.ARRAY_BUFFER, this.cubeMesh.nbo);
this.gl.vertexAttribPointer(this.prg.aNormal, 3, this.gl.FLOAT, false, 0, 0);

И функция создания куба createCube конечно же претерпела изменения, т.к. нужно задать массив нормалей:

createCube: function() {
    var halfSize = 20;
    var vertices = [
        halfSize, halfSize, halfSize, -halfSize, halfSize, halfSize, -halfSize, -halfSize, halfSize, halfSize, -halfSize, halfSize, // 0-1-2-3 front 0 1 2 3
        halfSize, -halfSize, halfSize, halfSize, -halfSize, -halfSize, halfSize, halfSize, -halfSize, halfSize, halfSize, halfSize, // 3-7-4-0 right 4 5 6 7
        halfSize, halfSize, halfSize, halfSize, halfSize, -halfSize, -halfSize, halfSize, -halfSize, -halfSize, halfSize, halfSize, // 0-4-5-1 up 8 9 10 11
        -halfSize, halfSize, halfSize, -halfSize, halfSize, -halfSize, -halfSize, -halfSize, -halfSize, -halfSize, -halfSize, halfSize, // 1-5-6-2 left 12 13 14 15
        halfSize, -halfSize, halfSize, -halfSize, -halfSize, halfSize, -halfSize, -halfSize, -halfSize, halfSize, -halfSize, -halfSize, // 3-2-6-7 down 16 17 18 19
        halfSize, halfSize, -halfSize, halfSize, -halfSize, -halfSize, -halfSize, -halfSize, -halfSize, -halfSize, halfSize, -halfSize // 4-7-6-5 back 20 21 22 23
    ];
    var indices = [
        0, 1, 2, 0, 2, 3, // front
        4, 5, 6, 4, 6, 7, // right
        8, 9, 10, 8, 10, 11, // up
        12, 13, 14, 12, 14, 15, // left
        16, 17, 18, 16, 18, 19, // down
        20, 21, 22, 20, 22, 23 // back
    ];
    var normals = [
        0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, // front
        1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0, // right
        0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0, // up
        -1.0, 0.0, 0.0, -1.0, 0.0, 0.0, -1.0, 0.0, 0.0, -1.0, 0.0, 0.0, // left
        0.0, -1.0, 0.0, 0.0, -1.0, 0.0, 0.0, -1.0, 0.0, 0.0, -1.0, 0.0, // down
        0.0, 0.0, -1.0, 0.0, 0.0, -1.0, 0.0, 0.0, -1.0, 0.0, 0.0, -1.0 // back
    ];
    this.cubeMesh = new CubeMesh(vertices, indices, normals);
}

Итого, добавилось еще около 50 строчек кода и освещение готово. Можно заметить, что благодаря строчке

this.light.position = this.camera.position;

источник света перемещается вместе с камерой - демо.

Используем Three.js

Three.js - одна из самых первых и самых популярных библиотек.
Первый релиз ее состоялся еще в 2010 году. Изначально она является портом с ActionScript на JavaScript, т.е. разработана она была еще раньше.
Ссылка на официальный сайт Three.js.

Итак, реализуем функционал на Three.js.
В html поменяем только путь к скрипту и добавим загрузку библиотеки Three.js.
Структура приложения остается та же:

var PI_180 = Math.PI / 180;
var Cube = function(canvasId) {
    this.canvas = document.getElementById(canvasId);

    ...
};
Cube.prototype = {
    createCube: function() {
        ...
    },
    run: function() {
        ....
    },
    update: function() {
        ...
    },
    checkRotateLimits: function() {
        ...
    }
};
window.addEventListener('DOMContentLoaded', function() {
    window.cube = new Cube('appCanvas');
});

Самые важные понятия в 3d-фреймворках это: renderer или engine, scene и camera. Начнем с инициализации и настройки этих трех элементов в конструкторе приложения.
Three.js renderer:

try {
    this.renderer = new THREE.WebGLRenderer({
        antialias: true,
        canvas: this.canvas
    });
} catch(e) {
    document.body.innerHTML = 'Unfortunately your browser is not supported';
    return;
}

Как видно, дополнительные опции передаются в конструкторе в виде объекта.
Т.к. элемент канвас уже существует, его нужно передать в конструктор.
Если этого не сделать, WebGLRenderer сам его создаст, после чего нужно будет добавить созданный элемент канвас в DOM-дерево: document.body.appendChild( renderer.domElement ).
На случай, если браузер не поддерживает WebGL или поддержка WebGL выключена, мы перехватываем ошибки и выводим сообщение пользователю в блоке catch.

Таким образом можно указать renderer, что канвас должен занимать всю клиентскую область экрана:

this.renderer.setSize(window.innerWidth, window.innerHeight, true);

Последний параметр называется updateStyle и заставляет поменять у канваса свойства style.width и style.height.

Устанавливаем цвет фона:

this.renderer.setClearColor(0x000000);

Создаем сцену, которая будет содержать все графические объекты:

this.scene = new THREE.Scene();

В Three.js есть несколько камер. Мы будем использовать камеру с перспективной проекцией.

this.camera = new THREE.PerspectiveCamera(fov, window.innerWidth / window.innerHeight, 1, 260);

Аргументы конструктора практически те же, что нужны нам были при создании оригинального приложения на нативном WebGL. Нам нужно указать fov (field of view) в градусах (в самостоятельно реализованной камере в радианах), aspect ratio (в самостоятельно реализованной камере высчитывался в коде камеры), near - расстояние до ближней плоскости отсечения области видимости, far - расстояние до дальней плоскости отсечения.

Чтобы указать, на какую точку должна смотреть камера, нужно вызвать метод lookAt:

this.camera.lookAt(new THREE.Vector3(0, 0, 0));

Для указания позиции камеры используем просто свойство position:

this.camera.position.set(-95, 95, 95);

Кстати, position является экземпляром THREE.Vector3. Чтобы добавить камеру к сцене нужно выполнить следующий код:

this.scene.add(this.camera);

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

var light = new THREE.PointLight(0xffffff, 2, 0);

Цвет света - это первый аргумент. Второй - интенсивность света. Третий аргумент позволяет влиять на эффект затухания света при удалении от него и это расстояние, где интенсивность равна нулю. Если указать ноль, то затухание будет отсутствовать. Именно такое поведение без эффекта затухания в оригинальном приложении, поэтому я указал 0.

Следующий участок кода привяжет источник света к камере:

this.camera.add(light);

Этого мы достигали в первом приложении с помощью this.light.position = this.camera.position;.

Задача сохранения пропорций изображения и его разрешения решается таким образом:

var $this = this;
window.addEventListener('resize', function() {
    $this.renderer.setSize(window.innerWidth, window.innerHeight, true);
    $this.camera.aspect = window.innerWidth / window.innerHeight;
    $this.camera.updateProjectionMatrix();
});

Последние две строчки очень важны. Они позволяют обновить свойство aspect камеры и ее матрицу проекций.

В методе createCube, как и прежде, создается главный и единственный геометрический объект сцены - куб.
Mesh - еще одно важно понятие, которое используется в мире 3d.
Mesh - какой-либо объект сцены и объединяет в себе как информацию о его геометрии, так и о внешнем виде и его физический свойствах.
Для создания Mesh нужно создать отдельно объект с информацией о геометрии и объект с информацией о материале объекта.
Three.js поддерживает много различных геометрий. Можно даже создать геометрию с помощью набора вершин и индексов, как мы делали при использовании WebGL напрямую.

var geometry = new THREE.BoxGeometry(40, 40, 40);

Как можно догадаться, все три аргументы - это размеры куба по соответствующим осям: x, y и z.

Создание материала:

var material = new THREE.MeshLambertMaterial({color: 0x4d4db2, reflectivity: 0});

Названия параметров говорят сами за себя.
Опять же таки, в Three.js можно использовать много различных материалов. Я выбрал MeshLambertMaterial, который рассчитывает освещение по методу Ламберта в вершинном шейдере. Тот же метод использовался в первом приложении, и можно увидеть, что в результате характер освещения в обоих случаях практически неотличим.

Теперь можно создать экземпляр THREE.Mesh и добавить его к сцене:

this.cubeMesh = new THREE.Mesh(geometry, material);
this.scene.add(this.cubeMesh);

Рассмотрим немного метод run.

run: function() {
    var render;
    var $this = this;
    render = function() {
        $this.update();
        $this.renderer.render($this.scene, $this.camera);
        window.requestAnimationFrame(render);
    };
    render();
}

Я думаю, можно легко увидеть соответствие между методами run в нативном приложении и приложении на Three.js.
Главными составляющими являются вызов метода render у объекта renderer, который прорисовывает сцену и строка window.requestAnimationFrame(render); для того, чтобы следующий фрейм был прорисован.

В методе update мы изменяем свойства rotation.x и rotation.y у cubeMesh, чтобы куб вращался вокруг осей x и y соответственно. Вращение задается в радианах.

    this.cubeMesh.rotation.x = this.rotationX * PI_180;
    this.cubeMesh.rotation.y = this.rotationY * PI_180;

Методы update и checkRotateLimits обеспечивают нужный характер вращения с ограничениями.
Наверное, вы уже заметили, что WebGL - это машина состояний.

Порт оригинального приложения на Three.js готов, для чего понадобилось около 85 строчек кода.
Демо реализации на Three.js.

Используем BabylonJS

BabylonJS моложе Three.js. Первый релиз состоялся в 2013 году. Но BabylonJS стремительно развивается и сейчас является одним из самым популярных WebGL-фреймворков. Ссылка на официальный сайт BabylonJS.

Настал черед портировать приложение на BabylonJS. На самом деле большинство фреймворков используют одинаковые понятия и многое окажется сходным с реализацией на Three.js. Структура приложения осталась та же, методы update и checkRotateLimits вообще не нужно менять.

Проверить поддержку WebGL браузером в BabylonJS можно следующим образом:

if (!BABYLON.Engine.isSupported()) {
    document.body.innerHTML = 'Unfortunately your browser is not supported';
    return;
}

После этой проверки создадим экземпляр BABYLON.Engine, который является аналогом THREE.WebGLRenderer. Первым аргументом передается элемент canvas. Второй включает / отключает поддержку сглаживания (antialias).

this.engine = new BABYLON.Engine(this.canvas, true);

Создание и настройка сцены

this.scene = new BABYLON.Scene(this.engine);
this.scene.clearColor = new BABYLON.Color3(0, 0, 0);
this.scene.ambientColor = new BABYLON.Color3(1, 1, 1);

В качестве камеры я выбрал ArcRotateCamera. Нужно заметить, что BabylonJS камера отвечает и за ее управление. Данная камера вращается вокруг указанной точке по сфере с указанным радиусом.

var camera = new BABYLON.ArcRotateCamera('camera', -1, 1, -130, new BABYLON.Vector3(0, 0, 0), this.scene);
camera.setPosition(new BABYLON.Vector3(-95, 95, 95));

Первый аргумент - название камеры. Никто не мешает использовать несколько камер в одной сцене, существуют методы для получения камеры по ее имени.
Второй и третий параметры (свойства камеры alpha и beta) указывают углы поворота камеры по осям x и y (можно провести аналогию с широтой и долготой).
Четвертый аргумент конструктора камеры - радиус воображаемой сферы (ее свойство radius), на которой располагается камера.
Пятый параметр - точка на которую направлена камера. Шестой параметр - сцена, к которой камера относится.
В нашем случае, второй, третий и четвертый параметры не играют роли, т.к. далее по коду мы устанавливаем позицию камеры в точку -95, 95, 95, что в свою очередь изменяет соответствующие ее свойства (alpha, beta, radius).

Создание точечного источника света в указанной позиции и настройка некоторых его параметров:

var light = new BABYLON.PointLight('light', new BABYLON.Vector3(0, 0, 0), this.scene);
light.specular = new BABYLON.Color3(0, 0, 0);
light.intensity = 0.2;

Позиция источника света, указанная в конструкторе неважна, т.к. с помощью

light.parent = camera;

мы привязываем источник света к камере. Таким образом, через свойство parent можно связать объекты в BabylonJS.

Для того, чтобы картинка не искажалась при изменении размеров браузера, в BabylonJS достаточно сделать следующее

var $this = this;
window.addEventListener('resize', function(){
    $this.engine.resize();
});

Т.е. все, что нужно - это вызвать метод resize у объекта engine.

Создание Mesh в BabylonJS выглядит следующим образом

this.cubeMesh = BABYLON.Mesh.CreateBox('box', 40, this.scene);
var material = new BABYLON.StandardMaterial('material', this.scene);
material.diffuseColor = new BABYLON.Color3(0.3, 0.3, 0.7);
material.ambientColor = new BABYLON.Color3(0.3, 0.3, 0.7);
this.cubeMesh.material = material;

В BabylonJS, как в Three.js заготовлено много различных геометрических объектов для использования и различные виды материалов.

Так выглядит метод run:

run: function() {
    var $this = this;
    this.scene.registerBeforeRender(function() {
        $this.update();
    });
    this.engine.runRenderLoop(function(){
        $this.scene.render();
    });
}

Нет необходимости использовать requestAnimationFrame. Вместо этого используется engine.runRenderLoop c указанием функции, которая должна выполняться в каждом фрейме. Для прорисовки сцены необходимо вызвать render

$this.scene.render();

Можно зарегистрировать функции с помощью scene.registerBeforeRender и scene.registerAfterRender (названия говорят сами за себя).

Как замечено выше, методы update и checkRotateLimits не поменялись. Каждый Mesh в BabylonJS содержит свойства с такими же именами, как в Three.js для вращения и изменения позиции: rotation и position.

Реализация на BabylonJS готова, понадобилось около 80 строчек кода.
Демо реализации на BabylonJS.

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

“A complete JavaScript framework for building 3D games with HTML5, WebGL and Web Audio”

А Three.js позиционируется как 3D-библиотека общего назначения:

“A JavaScript 3D Library which makes WebGL simpler.”

В то же время, в Three.js многое компенсируется плагинами.

В целом, оба рассматриваемых средства разработки WebGL-приложений обладают хорошими возможностями и активно разрабатываются.

Еще пару слов

Теперь пару слов о Three.js и BabylonJS с точки зрения организации этих проектов.
BabylonJS - это хорошая, вовремя обновляемая документация с обучающими материалами, очень отзывчивый форум (мне показалось deltakosh не пропускает ни единого сообщения :) ), наличие roadmap.
Документация на Three.js часто является устаревшей, многое приходится искать непосредственно в исходниках. Классы для работы с управлением камеры я почему-то нашел в разделе examples на github, а не в составе библиотеки. Ресурсами для помощи по Three.js могут служить stackoverflow и канал irc.

Среди других средства разработки WebGL-приложений упомяну PlayCanvas, главным достоинством которого является редактор с возможностью одновременной многопользовательской разработки. PlayCanvas бесплатен только для публичных проектов.
Известный игровой движок Unity на данный момент имеет возможность сборки приложений на WebGL. Правда, генерируемый код получается излишне большим.



 


Самый-самый блог
Блогер Рыбалка
Рыбалка
по среднему баллу (5.00) в категории «Спорт»


Загрузка...Загрузка...
BlogRider.ru не имеет отношения к публикуемым в записях блогов материалам. Все записи
взяты из открытых общедоступных источников и являются собственностью их авторов.