
Перед Новым годом на хабре были опубликованы два топика (первый, второй) о создании «Солнечной системы» на HTML5 Canvas. Бегло прочитав их и изучив результаты профилирования я удивился тому что такая простенькая программа так неэффективно работает. Вооружившись Notepad++ решил проверить всё ли так плохо, написав свою реализацию.
ТЗ остаётся всё тем же. 12 планет, скорость вращений первой — 40 секунд, каждой последующей на 20 секунд дольше. Изначально планеты имеют случайное расположение на своих орбитах. У каждой планеты есть описание, которое отображается при наведении курсора на неё. При клике на планету она останавливается. Если курсор находиться над орбитой — подсветить её. Всё это должно работать в Opera 12+, IE9+, Chrome и FF.
— Я не хочу ничего читать, давай результат!
— Держи: жмяк
Приступим. Создаю новую директорию в публичной папке Dropbox. Стандартно делю проект на каталоги js/css/img, в корне создаю файл main.html, который объединяет набор скриптов в одно целое.
Первые строчки
В наследие от предыдущих реализаций мне достались три картинки: солнце, задний фон и тайлы планет (на самом деле картинок больше). Отлично, теперь нужно как-то загрузить ресурсы в приложение, а за одно и описать структурные объекты. К слову, объектов у меня будет четыре: Point, Orbit, Planet и Tile. По порядку о каждом. Point это служебный объект, имеет два поля, x и y — положение точки на холсте, и несколько методов:.set(), .clone(), .getDis() — установить значения координат, клонировать объект и посчитать расстояние до другой точки. Объект Orbit содержит центр орбиты, её радиус и планету, которая движется по ней. (В идеале орбиты должны описываться формулами, но это в идеале, а у меня все орбиты — окружности). Третий объект — Planet. Планета имеет имя, точку расположения центра на холсте, радиус, скорость перемещения, и угол наклона в градусах. Последний объект Tile хранит изображение и четыре значения описывающие положение рисунка планеты на изображении: координаты верхнего левого угла, высоту и ширину. Тайл обладает методом .draw(x, y), который рисует его на холсте в указанной точке.
// Point.js function Point(x, y) { this.x; this.y; this.set(x, y); // Установить координаты }; Point.prototype = { set: function(x, y) { this.x = x || 0; this.y = y || 0; }, getDis: function(other) { return Math.sqrt(Math.pow(other.x - this.x, 2) + Math.pow(other.y - this.y, 2)); }, clone: function() { return new Point(this.x, this.y); } }; // Orbit.js function Orbit(center, radius) { this.center = center; this.radius = radius; this.planet = null; // Сначала у орбиты нет плаенты this.ctx = null; this.mouse = null; }; // Planet.js function Planet(orbit, radius, time) { this.pos = new Point(0, 0); this.orbit = orbit; this.radius = radius; this.speed = Math.PI*2 / (time * 1000); // Радиан в миллисекунду this.angle = ~~(Math.random() * 360); // Случайное положение планеты this.animate = true; this.name; this.tile; this.ctx; this.orbit.setProperty({'planet': this}); // Сообщить орбите о планете }; // Tile.js function Tile(ctx, img, x, y, w, h) { this.ctx = ctx; // Ссылка на канву this.img = img; // Ссылка на объект-изображение this.x = x; this.y = y; this.width = w; this.height = h; }; Tile.prototype = { draw: function(x, y) { this.ctx.drawImage(this.img, this.x, this.y, this.width, this.height, x, y, this.width, this.height); } }; /** * @param (object) property Список полей которые нужно добавить объекту * @param (bool) add Если у объекта нет полей передаваемых в property, стоит ли их создать */ Object.prototype.setProperty = function(property, add) { if (add !== true) add = false; for (var key in property) { if (property.hasOwnProperty(key)) { if (typeof this[key] !== 'undefined' || add) { this[key] = property[key]; } } } return this; }
Что бы не писать для каждого объекта свой сетер, я решил считерить и создать функцию .setProperty() в прототипе Object. Функция добавляет новые поля и меняет значения у старых.
Вернёмся к загрузчику изображений. С его реализацией я замораживаться не стал, и сделал традиционным методом: при добавлении нового изображения увеличиваю счётчик добавленных изображений, после загрузки изображения — счётчик загруженных. Если значения счётчиков совпали, то всё ресурсы загрузились и можно стартовать приложение. Есть у это разгрузчика один большой минус, нельзя динамически подгружать данные, но в моём случае в этом нет необходимости.
var IM = { // Images Manager store: {}, // Массив картинок imagesAdded: 0, // Сколько добавлено imagesLoaded: 0, // Сколько загружено add: function(url, name) { // Функция добавления var self = this; var img = new Image(); img.onload = function() { self.imagesLoaded++; if (self.imagesAdded == self.imagesLoaded) { self.afterRun(); // Запуститься, если всё будет загружено } } img.src = url; this.store[name] = img; this.imagesAdded++; }, afterRun: function() { // Что делать после загрузки render(new Date() * 1); // Передаю время запуска рендера внутрь } }; IM.add('img/sun.png', 'sun'); // Загрузить картинку IM.add('img/planets.png', 'planets'); // И ещё одну
Планеты
Пришло время рисовать планеты, но сначала их нужно инициализировать. Создаём новый экзмепляр объекта Planet, в него передаём орбиту, радиус планеты и время полного вращение вокруг центра системы (в секундах), а так же дополнительные свойства: имя, тайл и контекст. Солнце, кстати, тоже планета, но с нулевым радиусом у орбиты.
var planets = []; // Массив планет var mouse = {}; // Будущий контроллер мыши var globalCenter = new Point(canvas.width / 2, canvas.height / 2); // Центр системы // Новая орбита с центром globalCenter и радиусом ноль var orbit = new Orbit(globalCenter.clone(), 0).setProperty({ ctx: ctx, // контекст mouse: mouse // контроллер мыши, которого ещё нет }, true); // Новая планета с радиусом 50 и скоростью движени 1. А так же с тайлом и именем. var planet = new Planet(orbit, 50, 1).setProperty({ tile: new Tile(this.ctx, this._resources['sun'], 0, 0, 100, 100), name: 'Sun', ctx: ctx }, true); planets.push(planet); // Список имён var names = ['Moon', 'Phobos', 'Deimos', 'Dactyl', 'Linus', 'Io', 'Europa', 'Ganymede', 'Callisto', 'Amalthea', 'Himalia', 'Elara', 'Pasiphae', 'Taurus', 'Sinope', 'Lysithea', 'Carme', 'Ananke', 'Leda', 'Thebe', 'Adrastea', 'Metis', 'Callirrhoe', 'Themisto', '1975', '2000', 'Megaclite', 'Taygete', 'Chaldene', 'Harpalyke']; var tiles = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]; // Сдвиги тайлов вправо var time = 40; shuffle(names); // Перемешиваю массивы shuffle(tiles); for (var i = 0; i < 12; ++i) { // Первая планета удалена на 90 пикселей от центра, каждая последующая ещё на 26 orbit = new Orbit(globalCenter.clone(), 90+i*26).setProperty({ ctx: this.ctx, mouse: this.mouse }, true); planet = new Planet(orbit, 13, time).setProperty({ tile: new Tile(this.ctx, this._resources['planets'], tiles[i]*26, 0, 26, 26), name: names[i], ctx: this.ctx }, true); this.planets.push(planet); time += 20; }
Отлично, теперь есть планеты, но вот проблема, они ещё не умеют двигаться и не знают как нарисовать себя. Нужно исправить! Создаю функцию render(lastTime), которая принимает время последнего обновления сцены. Ренден запускает методы отрисовки у планет и следит за временем. Далее в прототипе Planet создаю метод .redner(deltaTime), который принимает время, прошедшее с последнего обновления сцены. Функция рассчитывает положение планеты с учётом времени и рисует планету в обновленных координатах. Так же на будущее создаю функцию .showInfo() для отображения информации о планете.
function render(lastTime) { var curTime = new Date(); requestAnimationFrame(function(){ render(curTime); }); ctx.clearRect(0, 0, canvas.width, canvas.height); for (var i = 0, il = planets.length; i < il; ++i) { planets[i].render(curTime - lastTime); } } Planet.prototype = { drawBorder: function() { // Обводка планеты var ctx = this.ctx; ctx.beginPath(); ctx.arc(this.pos.x, this.pos.y, this.radius * 1.1, 0, Math.PI * 2, true); ctx.closePath(); ctx.stroke(); }, showInfo: function() { var x = this.pos.x + this.radius * 0.7; // В какую точку нарисовать подсказку var y = this.pos.y + this.radius * 0.9; // по ox и oy ctx.fillStyle = '#002244'; ctx.fillRect(x, y, 100, 24); ctx.fillStyle = '#0ff'; ctx.fillText(this.name, x + 50, y + 17); }, render: function(deltaTime) { // r(fi) = radius, r - смещение, fi - угол в градусах this.pos.x = this.orbit.globalCenter.x + this.orbit.radius * Math.cos(this.angle); this.pos.y = this.orbit.globalCenter.y + this.orbit.radius * Math.sin(this.angle); this.angle += this.speed * deltaTime; // Увеличиваю угол if (typeof this.tile !== 'undefined') { // Если у планеты есть тайл то рисую её this.tile.draw(this.pos.x - this.radius, this.pos.y - this.radius); } } };
Запускаю, исправляю ошибки, опять запускаю и ура: планеты кружатся вокруг статичного Солнца.
Осталось совсем чуть-чуть: отобразить орбиты, анимацию их выделения и отображение информации о планетах. Нужна информация о мыше, а именно куда она движется, движется ли, нажаты или отжаты ли кнопки на ней. За её поведением над канвасом будет следить MouseController. Имея информацию о координатах указателя можно определить событие hover. Если модуль разности положения курсора и центра орбиты меньше некоторого значения (у меня это 14px), то это и есть hover. Теперь если событие ховер присутствует, то рисуется окружность вокруг центра орбиты линией пожирнее, та часть её, над которой находиться планета удаляется и на этом месте рисуется ещё одна окружность вокруг, но уже вокруг планеты планеты. Если ховера нет, то рисуется цельная окружность худой линией.
С отображением описания планет всё проще. Определяем над какой планетой находится курсор, и этой планеты вызываем .showInfo(). Есть одно но, подсказку на холст нужно рисовать последней, иначе другие объекты могу нарисоватся поверх неё.
Orbit.prototype = { draw: function() { var ctx = this.ctx; var hover = this.mouse && Math.abs(mouse.pos.getDis(this.center) - this.radius) < 13; // Вот он ховер if (hover) { // Выделенная орбита ctx.lineWidth = 2; ctx.strokeStyle = 'rgb(0,192,255)'; ctx.beginPath(); // Орбита ctx.arc(this.center.x, this.center.y, this.radius, 0, Math.PI * 2, true); ctx.closePath(); ctx.stroke(); if (typeof this.planet !== null) { // Если на орбите есть планета // Сначала почистю кусок где находится планета ctx.clearRect(this.planet.pos.x - this.planet.radius, this.planet.pos.y - this.planet.radius, this.planet.radius * 2, this.planet.radius * 2); // И на его месте нарисую окружность вокруг планеты this.planet.drawBorder(); } } else { // Обычная орбита ctx.lineWidth = 1; ctx.strokeStyle = 'rgba(0,192,255,0.5)'; ctx.beginPath(); ctx.arc(this.center.x, this.center.y, this.radius, 0, Math.PI * 2, true); ctx.closePath(); ctx.stroke(); } } function render(lastTime) { var curTime = new Date(); requestAnimationFrame(function(){ render(curTime); // Заказать на рисование следующий кадр }); ctx.clearRect(0, 0, canvas.width, canvas.height); // Очистить всё var showInfo = -1; // Индекс планеты у которой нужно вывести описание for (var i = 0, il = planets.length; i < il; ++i) { // Перебор планет planets[i].orbit.draw(); // Рисую орбиты planets[i].render(curTime - lastTime); // Рисую планеты if (Math.abs(planets[i].pos.x-mouse.pos.x) < planets[i].radius // Есть ли ховер над планетой && Math.abs(planets[i].pos.y-mouse.pos.y) < planets[i].radius) { showInfo = i; // Если да, то над какой //if (mouse.pressed) { // Остановить планету если был клик по ней // planets[i].animate = planets[i].animate ? false : true; //} } } if (showInfo > -1) { // Показать информацию о планете, изменить курсор planets[showInfo].showInfo(); document.body.style.cursor = 'pointer'; } else { document.body.style.cursor = 'default'; } } }; Остановку по клику я вводить не стал. Позже переложил код в аккуратный объект App.
Выводы
В теории идея где каждый элемент рисуется на определенное полотно должна обеспечить лучшую производительность, и наверняка это так для объёмных приложений. Но в маленьких приложениях это правило не работает, там где нет сложных анимаций незачем создавать много полотен.
Результаты профилирования на моём ПК (AMD Athlon64 х2 4600+ 2,4GHz, GeForce 210).
Оригинал:
На LibCanvas (похоже что у него ограничение в 60 fps):
Моя реализация:
Спасибо за внимание.
ссылка на оригинал статьи http://habrahabr.ru/post/164899/
Добавить комментарий