Почему HTML5 Canvas не является альтернативой для Flash

Jan 06, 2012 20:56


Несколько дней на новогодних каникулах посвятил детальному изучению HTML5 Canvas. В поисках спецификации выбрал самый авторитетный источник: рекомендацию W3C (http://www.w3.org/TR/2dcontext). Исходя из этой спецификации, пришел к грустному выводу: HTML5 не является полноценной альтернативой Flash.

Статья получилась больше, чем я ожидал. Надеюсь, кто-нибудь найдет в себе силы прочитать и подсказать возможные пути решения указанных проблем. В тестировании перечисленных результатов мне помогал Юрий Поздняков.
SVG vs Canvas

Думаю, никто не будет спорить с тем, что для SVG титул альтернативы Flash точно не подходит: слишком уж она тормознутая. Связано это с тем, что за основу реализации этой технологии взята текстовая (XML) модель документа. Поскольку это текст, значит все теги и атрибуты изначально представляются в виде текста, а лишь потом, где-то в ядре SVG-движка, преобразуются в нормальные данные: числовые строчки превращаются в числа, строчки путей (path) превращаются в массивы точек и операций между ними и т.д. И чтобы окончательно добить производительность, это загнано в рамки XML-документа, который испокон веков является очень тормознутым стандартом (это одна из многих причин, по которым сейчас веб-разработчики все больше отдают свое предпочтение JSON). Все мои рассуждения подкреплены тем фактом, что мой рабочий проект, представляющий собой крупное SVG-приложение, имеет большие проблемы со скоростью рендеринга. Анимация превращается в слайд-шоу, drag & drop становится неприятным и пр.

Ну это понятно, никто и не предполагал использование SVG в качестве движка для полноценных графических приложений, подобных Flash. В качестве такого движка изначально рекомендовали использовать Canvas - в подтверждение этому есть множество публикаций в Интернете. Есть множество статей, посвященных разработке простых игр с помощью Canvas; есть целые сайты, сборники таких статей. Есть множество простых примеров для начинающих. Есть и более сложные проекты: разные интересные игры на основе Canvas, но исходники у них, как правило, закрыты. Ну что ж, значит имеет смысл детально изучить Canvas и начать на нем что-то программировать.

Первое бросается в глаза то, что у Canvas нет понятия DOM. Canvas - это всего-навсего плоская сетка пикселей, на которой можно что-то рисовать карандашом и кисточкой. Нет ни визуальных компонентов, ни конвертирования координат, ни bubble'инга событий, ни прочих прелестей, к которым мы привыкли, программируя на Flash. Ну что ж, Москва тоже не сразу строилась. Как бы то ни было, нам дали возможность рисовать сколь угодно сложные вещи (кривые, полигоны, картинки и пр.), а обшить это всякими современными прелестями UI-разработки мы сможем, наверно.

Подумав, что кто-то может быть уже решил эту задачу, я занялся поиском. Но ничего не нашел. Все фреймворки для работы с канвой даже издалека не подходят к этому вопросу. Везде какие-то подобия RaphaelJS (популярный SVG фреймворк), когда дают целую тонну примочек для рисования разных примитивов, а об ООП-обертке даже не задумываются. Ну почему большинство программистов до сих пор считают JavaScript каким-то быдлоязыком для добавления простой динамики на HTML-страничку? Это так грустно...

Я поставил перед собой цель: разработать такой фреймворк на основе Canvas, API которого максимально приближено к Flash API. А именно: у нас есть дерево компонентов, имеющих определенные свойства (координаты, угол поворота, масштаб по осям, флаг видимости и пр.) с интерфейсом для рисования графики (поле graphics) и умеющих адекватно выбрасывать события мыши; пользователь строит это дерево компонентов, может его динамически менять, а движок будет все покадрово перерисовывать, с заданным FPS.

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

Задача. Пользователь кликнул мышью. Нам известны глобальные координаты клика внутри . Необходимо ответить на следующие вопросы:
  1. По какому компоненту кликнули (т.е. какой будет target у сгенерированного события, которое мы потом будем bubble'ить до самого корня)?
  2. Каковы локальные координаты мыши внутри этого компонента и всех его родителей
Проблема клика мыши: вычисление локальных координат

Ответить на второй вопрос проще, поэтому начну с него. Графика каждого компонента рисуется в определенной системе координат. Система координат хранится в контексте рисования и может быть изменена с помощью следующих методов: translate, rotate, scale, transform и setTransform. Кроме того, система координат является частью "состояния контекста", поэтому ее можно в любой момент сохранить в стек методом save и восстановить методом restore. Когда мы спускаемся вниз по дереву компонентов, мы просто-напросто сохраняем состояние в стек (save), применяем нужные преобразования плоскости, рисуем графику, рисуем дочерние компоненты, после чего восстанавливаем состояние контекста:

context.save(); context.translate(this.x, this.y); context.rotate(this.rotate); context.scale(this.scaleX, this.scaleY); context.save(); this.drawGraphics(); // Здесь рисуется графика, сохраненная в this.graphics context.restore(); this.children.eachByMethod("draw"); // Рисуем дочерние компоненты context.restore(); * This source code was highlighted with Source Code Highlighter.

И как раз после translate, rotate и scale хочется написать:

var localXY = context.globalToLocal(globalXY); * This source code was highlighted with Source Code Highlighter.

Ну или хотя бы так:

var transform = context.getTransform(); var localXY = [   transform[0] * globalXY[0] + transform[2] * globalXY[1] + transform[4],   transform[1] * globalXY[0] + transform[3] * globalXY[1] + transform[5] ]; * This source code was highlighted with Source Code Highlighter.

А борода! Нет у контекста ни globalToLocal, ни getTransform. То есть систему координат поменять вручную методом setTransform можно, а обратно ее получить нельзя. Это очень сильный удар в лицо всем программистам от разработчиков спецификации.

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

JW.Canvas.Transform = JW.Class.extend({   matrix : null, // Array[6]   // a c e   // b d f   // 0 0 1      init: function(matrix)   {     this.matrix = matrix || [ 1, 0, 0, 1, 0, 0 ];   },      convert: function(point)   {     return JW.Canvas.Transform.multMatrixVector(this.matrix, point);   },      complex: function(x, y, scaleX, scaleY, rotate)   {     var cos = Math.cos(rotate);     var sin = Math.sin(rotate);     return this.transform(scaleX * cos, scaleX * sin, -scaleY * sin, scaleY * cos, x, y);   },      transform: function(a, b, c, d, e, f)   {     return new JW.Canvas.Transform(JW.Canvas.Transform.multMatrix(this.matrix, arguments));   },      back: function()   {     return new JW.Canvas.Transform(JW.Canvas.Transform.backMatrix(this.matrix));   } }); JW.apply(JW.Canvas.Transform, {   identity: new JW.Canvas.Transform(),      multMatrixVector: function(m, u)   {     return [       m[0] * u[0] + m[2] * u[1] + m[4],       m[1] * u[0] + m[3] * u[1] + m[5]     ];   },      multMatrix: function(m, n)   {     return [       m[0] * n[0] + m[2] * n[1],       m[1] * n[0] + m[3] * n[1],       m[0] * n[2] + m[2] * n[3],       m[1] * n[2] + m[3] * n[3],       m[0] * n[4] + m[2] * n[5] + m[4],       m[1] * n[4] + m[3] * n[5] + m[5]     ];   },      backMatrix: function(m)   {     var d = m[0] * m[3] - m[1] * m[2];     return [        m[3] / d,       -m[1] / d,       -m[2] / d,        m[0] / d,       (m[2] * m[5] - m[3] * m[4]) / d,       (m[1] * m[4] - m[0] * m[5]) / d      ];   } }); * This source code was highlighted with Source Code Highlighter.

Теперь у каждого компонента есть методы для преобразования координат:

... globalToLocal: function(point) {   return this.getBackTransform().convert(point); }, localToGlobal: function(point) {   return this.getTransform().convert(point); }, getTransform: function() {   if (this.transform)     return this.transform;      if (!this.parent)     return JW.Canvas.Transform.identity;      this.transform = this.parent.getTransform().complex(this.x, this.y, this.scaleX, this.scaleY, this.rotate);   return this.transform; }, getBackTransform: function() {   if (this.backTransform)     return this.backTransform;      if (!this.parent)     return JW.Canvas.Transform.identity;      this.backTransform = this.getTransform().back();   return this.backTransform; }, _invalidateTransform: function() {   delete this.transform;   delete this.backTransform; }, ... * This source code was highlighted with Source Code Highlighter.

Теперь, пользуясь методом globalToLocal мы в любой момент можем узнать локальные координаты мыши внутри любого компонента.
Проблема клика мыши: по какому компоненту кликнули (hit test)

Для того, чтобы ответить на этот вопрос, вспомним, как hit test реализован во Flash. Считается, что точка принадлежит компоненту, если она принадлежит нарисованной на нем графике или хотя бы одному дочернему компоненту (с учетом свойств visible, mouseEnabled и mouseChildren). Допустим, у вас нарисована жирная пунктирная линия. Hit test успешен тогда и только тогда, когда точка лежит хотя бы на одном пунктире (т.е. там, где что-то нарисовано). Это очень удобная возможность Flash, которая позволяет решать задачи hit test'а любой сложности. Если вам нужна более широкая область hit test'а, просто-напросто рисуете прозрачную подложку под компонентом: ее не будет видно, но она будет реагировать на события мыши. Эти возможности я широко использовал на своих проектах, чтобы обеспечить высокий уровень UI usability.

А теперь посмотрим, какие средства для этого нам предлагает Canvas. Есть всего-навсего одна функция которая хоть каким-то боком касается затронутого вопроса: isPointInPath. Она вернет true, если указанная точка (почему-то в глобальных координатах) лежит внутри текущего пути.

Во-первых, не знаю, баг это или фича, но эта функция полностью игнорирует Clipping Region. То есть, если содержимое вашего компонента, вылазящее за заданные функцией clip пределы, визуально обрезано по бокам, то isPointInPath все равно там вернет true. Ну ладно, на это можно закрыть глаза, если clipping осуществлять ровно по прямоугольной области: в таком случае вылазящие точки можно с легкостью отфильтровывать вручную. Но для произвольных регионов clipping'а это работать не будет.

Во-вторых, чтобы воспользоваться этой функцией, нужно как-то синхронизировать обработку событий мыши с процессом глобальной перерисовки контекста. Потому что пути задаются именно во время рисования, а сохранить их невозможно. Та же проблема, что и с системами координат, но так же ее решить не представляется возможным, поскольку пути - штука очень тяжелая математически: разрабатывать ее на чистом JavaScript'е это все равно что писать 3D-стрелялку без графического ускорителя. Ну что ж, по каждому событию мыши давайте все перерисовывать. Я попробовал, и производительность упала в разы. Представьте, как много событий приходит каждую секунду, если непрерывно двигать мышку над канвой и спамить клики. Если графики достаточно много, то через несколько секунд программа насмерть зависает. Прикинув в уме, можно ли как-то реализовать "отложенную" обработку событий с использованием очереди, я понял, что в результате мы получим массу потенциальных багов, связанных с рассинхронизацией этих событий. Например, если на mouseup мы удаляем target-компонент, то ему, уже мертвому, все равно прийдет click, что даст непредсказуемые результаты. Даже пробовать не стал. Ладно, смиримся с тормозами...

Но третий косяк меня добил окончательно. Путь - не то же самое, что графика. Если вы нарисуете жирную линию, то соответствующий путь вырождается в отрезок нулевой толщины, а он по сути не содержит ни одной точки: по нему невозможно кликнуть. Вот пример:


DOCTYPE html>