Ещё одна «Солнечная cистема» на HTML5 Canvas

в 19:06, , рубрики: canvas, holywars, html5 canvas, javascript, Анимация и 3D графика, метки: ,

Ещё одна «Солнечная cистема» на HTML5 Canvas
Перед Новым годом на хабре были опубликованы два топика (первый, второй) о создании «Солнечной системы» на 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);
        }
    }
};

Запускаю, исправляю ошибки, опять запускаю и ура: планеты кружатся вокруг статичного Солнца.
image
Осталось совсем чуть-чуть: отобразить орбиты, анимацию их выделения и отображение информации о планетах. Нужна информация о мыше, а именно куда она движется, движется ли, нажаты или отжаты ли кнопки на ней. За её поведением над канвасом будет следить 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).
Оригинал:
Ещё одна «Солнечная cистема» на HTML5 Canvas
На LibCanvas (похоже что у него ограничение в 60 fps):
Ещё одна «Солнечная cистема» на HTML5 Canvas
Моя реализация:
Ещё одна «Солнечная cистема» на HTML5 Canvas

Спасибо за внимание.

Автор: vladkens

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js