[Перевод] Ускорение вывода диаграмм с использованием OffscreenCanvas
Рендеринг диаграмм может серьёзно нагрузить работой браузер. Особенно если речь идёт о выводе в интерфейсе сложного приложения множества элементов, представляющих диаграммы. Попытаться улучшить ситуацию можно с помощью интерфейса OffscreenCanvas
, уровень поддержки которого браузерами постепенно растёт. Он позволяет, задействовав веб-воркер, переложить на него задачи по формированию изображения, выводимого в видимый элемент .
Статья, перевод которой мы сегодня публикуем, посвящена использованию интерфейса OffscreenCanvas
. Здесь речь пойдёт о том, зачем может понадобиться этот интерфейс, о том, чего реально ожидать от его применения, и о том, какие сложности могут возникнуть при работе с ним.
Почему стоит обратить внимание на OffscreenCanvas?
Код, участвующий в рендеринге диаграммы, может и сам по себе обладать достаточно высокой вычислительной сложностью. Во многих местах можно найти подробные рассказы о том, что для вывода плавной анимации и для обеспечения удобного взаимодействия пользователя со страницей нужно, чтобы рендеринг одного кадра укладывался бы примерно в 10 мс, что позволяет достичь 60 кадров в секунду. Если вычисления, требуемые для вывода одного кадра, займут больше, чем 10 мс, это выразится в заметных пользователю «подтормаживаниях» страницы.
При выводе диаграмм, однако, ситуацию усугубляет следующее:
- Если выводятся большие наборы данных или множество аннотаций — требуется оптимизация медиа-типа (SVG, canvas, WebGL) для каждой серии данных или аннотации, или агрессивное отсечение всего того, что выходит за пределы видимой области диаграммы.
- Если диаграмма является частью большого приложения, нужно, чтобы рендеринг кадра стабильно укладывался бы во время, значительно меньшее, чем 10 мс. Нужно это для того чтобы у других элементов приложения (возможно — у других диаграмм) было бы время на рендеринг.
Эти проблемы — идеальные кандидаты на применение классического подхода к оптимизации, называемого «разделяй и властвуй». В данном случае речь идёт о распределении вычислительной нагрузки по нескольким потокам. Правда, до появления интерфейса OffscreenCanvas
весь код рендеринга необходимо было выполнять в главном потоке. Только так этот код мог пользоваться необходимыми ему API.
С технической точки зрения «тяжёлые» вычисления можно было выводить в поток веб-воркера и раньше. Но, так как вызовы, ответственные за рендеринг, необходимо было выполнять из главного потока, это требовало использования в коде вывода диаграмм сложных схем обмена сообщениями между потоком воркера и главным потоком. При этом такой подход часто давал лишь незначительный выигрыш в производительности.
Спецификация OffscreenCanvas
даёт нам механизм передачи управления поверхностью для вывода графики элемента в веб-воркер. Эта спецификация в настоящий момент поддерживается браузерами Chrome и Edge (после того, как Edge перевели на Chromium). Ожидается, что в Firefox поддержка
OffscreenCanvas
появится в течение полугода. Если говорить о Safari, то пока неизвестно, планируется ли поддержка этой технологии в данном браузере.
Вывод диаграммы с использованием OffscreenCanvas
Пример диаграммы, на которой выводится 100000 разноцветных точек
Для того чтобы лучше оценить потенциальные преимущества и недостатки OffscreenCanvas
, давайте рассмотрим пример вывода «тяжёлой» диаграммы, показанной на предыдущем рисунке.
const offscreenCanvas = canvasContainer
.querySelector('canvas')
.transferControlToOffscreen();
const worker = new Worker('worker.js');
worker.postMessage({ offscreenCanvas }, [offscreenCanvas]);
Сначала мы запрашиваем OffscreenCanvas
у элемента canvas
, используя новый метод transferControlToOffscreen()
. Затем мы вызываем метод worker.postMessage({ canvas: offscreenCanvas }, [offscreenCanvas])
, делая это для отправки воркеру сообщения, содержащего ссылку на offscreenCanvas
. Очень важно то, что здесь, в качестве второго аргумента, нужно использовать [offscreenCanvas]
. Это позволяет сделать владельцем данного объекта воркер, что позволит ему единолично управлять OffscreenCanvas
.
canvasContainer.addEventListener('measure', ({ detail }) => {
const { width, height } = detail;
worker.postMessage({ width, height });
});
canvasContainer.requestRedraw();
Учитывая то, что размеры OffscreenCanvas
изначально унаследованы от атрибутов width
и height
элемента canvas
, в наши обязанности входит поддержание этих значений в актуальном состоянии при изменении размеров элемента canvas
. Здесь мы используем событие measure
из d3fc-canvas
, что даст нам возможность, в согласовании с requestAnimationFrame
, передать воркеру сведения о новых размерах элемента canvas
.
Для того чтобы упростить пример, мы будем пользоваться компонентами из библиотеки d3fc. Это — набор вспомогательных компонентов, которые либо агрегируют компоненты d3, либо дополняют их функционал. Работать с OffscreenCanvas
можно и без d3fc-компонентов. Всё то, о чём пойдёт речь, можно сделать и пользуясь исключительно стандартными возможностями JavaScript.
Теперь переходим к коду из файла worker.js
. В данном примере, ради реального повышения производительности рендеринга, мы собираемся воспользоваться WebGL.
addEventListener('message', ({ data: { offscreenCanvas, width, height } }) => {
if (offscreenCanvas != null) {
const gl = offscreenCanvas.getContext('webgl');
series.context(gl);
series(data);
}
if (width != null && height != null) {
const gl = series.context();
gl.canvas.width = width;
gl.canvas.height = height;
gl.viewport(0, 0, gl.canvas.width, gl.canvas.height);
}
});
Когда мы получаем сообщение, содержащее свойство canvas
, мы предполагаем, что это сообщение пришло из главного потока. Далее, мы получаем из элемента canvas
контекст webgl
и передаём его компоненту series
. Затем мы вызываем компонент series
, передавая ему данные (data
), которые хотим с его помощью отрендерить (ниже мы поговорим о том, откуда взялись соответствующие переменные).
Кроме того, мы проверяем свойства width
и height
, пришедшие в сообщении, и используем их для задания размеров offscreenCanvas
и области просмотра WebGL. Ссылка offscreenCanvas
напрямую не используется. Дело в том, что сообщение содержит или свойство offscreenCanvas
, или свойства width
и height
.
Оставшийся код воркера отвечает за настройку рендеринга того, что мы хотим вывести. Тут нет ничего особенного, поэтому если всё это вам хорошо знакомо — можете сразу перейти к следующему разделу, в котором мы обсуждаем производительность.
const randomNormal = d3.randomNormal(0, 1);
const randomLogNormal = d3.randomLogNormal();
const data = Array.from({ length: 1e5 }, () => ({
x: randomNormal(),
y: randomNormal(),
size: randomLogNormal() * 10
}));
const xScale = d3.scaleLinear().domain([-5, 5]);
const yScale = d3.scaleLinear().domain([-5, 5]);
Сначала мы создаём набор данных, содержащий координаты точек, случайно распределённых вокруг начала координат x/y, и настраиваем масштабирование. Мы не задаём диапазон значений x и y, пользуясь методом range
, так как серии WebGL выводятся в полном размере элемента canvas
(-1 -> +1
в нормализованных координатах устройства).
const series = fc
.seriesWebglPoint()
.xScale(xScale)
.yScale(yScale)
.crossValue(d => d.x)
.mainValue(d => d.y)
.size(d => d.size)
.equals((previousData, data) => previousData.length > 0);
Далее, мы настраиваем серию точек, используя xScale
и yScale
. Затем настраиваем соответствующие средства доступа, которые позволяют выполнять чтение данных.
Кроме того, мы задаём собственную функцию проверки равенства, предназначенную для того чтобы компонент не передавал бы data
GPU при каждом рендеринге. Мы должны выразить это в явном виде, так как даже если мы знаем о том, что не будем модифицировать эти данные, компонент не может знать об этом без выполнения ресурсоёмкой «грязной» проверки.
const colorScale = d3.scaleOrdinal(d3.schemeAccent);
const webglColor = color => {
const { r, g, b, opacity } = d3.color(color).rgb();
return [r / 255, g / 255, b / 255, opacity];
};
const fillColor = fc
.webglFillColor()
.value((d, i) => webglColor(colorScale(i)))
.data(data);
series.decorate(program => {
fillColor(program);
});
Этот код позволяет раскрасить диаграмму. Мы пользуемся индексом точки в наборе данных для выбора подходящего цвета из colorScale
, затем преобразуем его в требуемый формат и используем для оформления выведенной точки.
function render() {
const ease = 5 * (0.51 + 0.49 * Math.sin(Date.now() / 1e3));
xScale.domain([-ease, ease]);
yScale.domain([-ease, ease]);
series(data);
requestAnimationFrame(render);
}
Теперь, когда точки раскрашены, осталось лишь анимировать диаграмму. Благодаря этому у нас будет необходимость в постоянном вызове метода render()
. Это, кроме того, создаст некоторую нагрузку на систему. Мы имитируем увеличение и уменьшение масштаба диаграммы, используя requestAnimationFrame
для модификации свойств xScale.domain
и yScale.domain
каждого кадра. Тут применяются значения, зависящие от времени и рассчитанные так, чтобы масштаб диаграммы менялся бы плавно. Кроме того, мы модифицируем заголовок сообщения так, чтобы для запуска цикла рендеринга вызывался бы метод render()
, и чтобы нам не приходилось бы напрямую вызывать series(data)
.
importScripts(
'./node_modules/d3-array/dist/d3-array.js',
'./node_modules/d3-collection/dist/d3-collection.js',
'./node_modules/d3-color/dist/d3-color.js',
'./node_modules/d3-interpolate/dist/d3-interpolate.js',
'./node_modules/d3-scale-chromatic/dist/d3-scale-chromatic.js',
'./node_modules/d3-random/dist/d3-random.js',
'./node_modules/d3-scale/dist/d3-scale.js',
'./node_modules/d3-shape/dist/d3-shape.js',
'./node_modules/d3fc-extent/build/d3fc-extent.js',
'./node_modules/d3fc-random-data/build/d3fc-random-data.js',
'./node_modules/d3fc-rebind/build/d3fc-rebind.js',
'./node_modules/d3fc-series/build/d3fc-series.js',
'./node_modules/d3fc-webgl/build/d3fc-webgl.js'
);
Для того чтобы этот пример заработал, осталось лишь импортировать в воркер необходимые библиотеки. Мы для этого используем importScripts
, а так же, чтобы не пользоваться инструментами сборки проектов, применяем список зависимостей, составленный вручную. К сожалению, мы не можем просто загрузить полные сборки d3/d3fc, так как они зависят от DOM, а в воркере то, что им нужно, недоступно.
→ Полный код этого примера вы можете найти на GitHub
OffscreenCanvas и производительность
Анимация в нашем проекте работает благодаря использованию requestAnimationFrame
из воркера. Это позволяет воркеру выполнять рендеринг страниц даже тогда, когда главный поток занят другими делами. Если взглянуть на страницу проекта, описанного в предыдущем разделе, и нажать на кнопку Stop main thread
, можно обратить внимание на то, что при выводе окна сообщения останавливается обновление сведений об отметке времени. Главный поток при этом блокируется, но анимация диаграммы не останавливается.
Окно проекта
Мы могли бы организовать рендеринг, инициируемый получением сообщений от главного потока. Например, такие события могли бы отправляться при взаимодействии пользователя с диаграммой или при получении свежих данных по сети. Но при таком подходе, если главный поток чем-то занят и в воркер не поступают сообщения, рендеринг остановится.
Непрерывный рендеринг диаграммы в условиях, когда на ней ничего не происходит — это отличный способ досадить пользователю жужжанием вентиляторов и разрядом батареи. В результате оказывается, что в реальном мире принятие решения о том, как именно разделить обязанности между главным потоком и потоком воркера, зависит от того, может ли воркер рендерить что-то полезное тогда, когда он не получает сообщений об изменении ситуации из главного потока.
Если говорить о передаче сообщений между потоками, то надо отметить, что тут мы применили очень простой подход. Честно говоря, нам надо передавать совсем немного сообщений между потоками, поэтому наш подход я скорее назвал бы следствием прагматизма, а не лени. Но если говорить о реальных приложениях, то можно отметить, что схема передачи сообщений между потоками усложнится в том случае, если от них будет зависеть взаимодействие пользователя с диаграммой и обновление визуализируемых данных.
Для создания отдельных каналов передачи сообщений между главным потоком и потоком воркера можно воспользоваться стандартным интерфейсом MessageChannel. Каждый из таких каналов может быть использован для особой категории сообщений, что позволяет упростить обработку сообщений. В качестве альтернативы стандартным механизмам могут выступить сторонние библиотеки наподобие Comlink. Эта библиотека скрывает низкоуровневые детали за высокоуровневым интерфейсом, используя прокси-объекты.
Ещё одной интересной возможностью нашего примера, которая становится ясно видна в ретроспективе, является тот факт, что в нём учитывается то, что ресурсы GPU, как и другие системные ресурсы, далеко не бесконечны. Перевод рендеринга в воркер позволяет главному потоку решать другие задачи. Но браузеру, всё равно, нужно обращаться к GPU для рендеринга DOM и того, что было сформировано средствами OffscreenCanvas
.
Если воркер потребляет все ресурсы GPU, тогда главное окно приложения будет испытывать проблемы с производительностью вне зависимости от того, где именно производится рендеринг. Обратите внимание на то, как падает скорость обновления отметки времени в примере при росте числа выводимых точек. Если у вас мощная видеокарта, то вам, возможно, придётся увеличить количество точек, передаваемое странице в строке запроса. Для этого вы можете использовать значение, превышающее максимальное значение в 100000, задаваемое с помощью одной из ссылок на странице примера.
Надо отметить, что тут мы не исследовали тайный мир атрибутов контекста. Такое исследование могло бы помочь нам в деле увеличения производительности решения. Однако я этого не сделал, поступив так из-за невысокого уровня поддержки этих атрибутов.
Если говорить о рендеринге с использованием OffscreenCanvas
, то тут интереснее всего выглядит атрибут desynchronized
. Он, там, где поддерживается, и с учётом некоторых ограничений, позволяет избавиться от синхронизации между циклом событий и циклом рендеринга, выполняемым в воркере. Это позволяет минимизировать задержку обновления изображения. Подробности об этом можно почитать здесь.
Итоги
Интерфейс OffscreenCanvas
даёт разработчикам возможность улучшить производительность рендеринга диаграмм, но его применение требует вдумчивого подхода. Пользуясь им, нужно учитывать следующее:
- Обработка взаимодействия пользователя с диаграммой (прокрутка, изменение масштаба, выделение элементов и так далее) усложняется из-за наличия границы между главным потоком и потоком воркера.
- Улучшить ситуацию можно, обеспечив чёткое разделение кода рендеринга и кода обработки взаимодействий. Кроме того, нужно учитывать то, что упростить работу с сообщениями могут помочь высокоуровневые библиотеки, позволяющие абстрагироваться от postMessage.
- Компоненты, использующие различные технологии для вывода диаграмм (например, наложение SVG/HTML-элементов на элементы
), будут, по вышеозначенным причинам, устроены сложнее.
- Тут, так же, как и прежде, упростить передачу сообщений между потоками можно, обеспечив чёткое разделение компонентов по используемым технологиям, и сделав так, чтобы компоненты, визуализирующие данные, отвечали бы исключительно за визуализацию.
- Рендеринг, создающий большую нагрузку на GPU, даже при применении
OffscreenCanvas
будет создавать на видеоподсистему такую же нагрузку.- Для того чтобы учесть эту особенность OffscreenCanvas, нужно обеспечить наличие у главного потока достаточных GPU-ресурсов, позволяющих выводить не только диаграмму, но и другие части документа. В противном случае то, что происходит в воркере, может негативно повлиять на производительность приложения.
Вот код примера, а вот — его рабочий вариант.
Уважаемые читатели! Пользовались ли вы OffscreenCanvas для ускорения вывода графики на веб-страницах?