Перед Новым годом на хабре были опубликованы два топика (первый, второй) о создании «Солнечной системы» на 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):
Моя реализация:
Спасибо за внимание.
Автор: vladkens