Коротко — две главные вещи, которые нужно делать правильно при динамическом масштабировании canvas:
1) управлять реальным буфером пикселей элемента (атрибуты canvas.width/height) в соответствии с CSS‑размерами *и* devicePixelRatio, а не полагаться только на CSS width/height;
2) не пересоздавать/перерисовывать всё синхронно на каждый событие resize — использовать requestAnimationFrame / дебаунс / offscreen‑рендеринг, ограничивать разрешение и учитывать максимумы GPU, чтобы не перегрузить CPU/GPU.
Ниже — подробности и готовый шаблон.
Почему размывается
- Если вы ставите в CSS width/height:100% и не меняете canvas.width/canvas.height, то браузер масштабирует растровый буфер на экранные пиксели → размытость на HiDPI.
- Правильно: CSS size (в CSS‑пикселях) должен совпадать с отображаемым размером, а реальный буфер должен быть CSS_size * devicePixelRatio.
Правильный алгоритм (пошагово)
1. Получаете видимый размер в CSS‑пикселях (например, getBoundingClientRect() или clientWidth/clientHeight).
2. Умножаете на devicePixelRatio (dpr = window.devicePixelRatio || 1) и округляете.
3. Проверяете — если canvas.width/height уже равны этим значениям, ничего не делаете.
4. Иначе устанавливаете canvas.width = w*dpr, canvas.height = h*dpr и canvas.style.width = w+'px', canvas.style.height = h+'px'.
5. Сбрасываете/устанавливаете transform контекста: ctx.setTransform(dpr,0,0,dpr,0,0) (или ctx.scale(dpr,dpr) после ctx.setTransform(1,0,0,1,0,0)) — тогда ваши координаты остаются в CSS‑пикселях.
6. Вызываете ререндер (желательно в requestAnimationFrame).
Пример функции (упрощённо)
```js
function resizeCanvasToDisplaySize(canvas, drawFn) {
const dpr = Math.max(1, window.devicePixelRatio || 1);
const rect = canvas.getBoundingClientRect();
const cssW = Math.round(rect.width);
const cssH = Math.round(rect.height);
// предохраняемся от нуля/слишком больших значений
const maxTexture = 16384; // подберите под целевую платформу
const w = Math.min(maxTexture, Math.max(1, Math.floor(cssW * dpr)));
const h = Math.min(maxTexture, Math.max(1, Math.floor(cssH * dpr)));
if (canvas.width !== w || canvas.height !== h) {
canvas.width = w;
canvas.height = h;
canvas.style.width = cssW + 'px';
canvas.style.height = cssH + 'px';
const ctx = canvas.getContext('2d');
ctx.setTransform(dpr, 0, 0, dpr, 0, 0); // теперь координаты в CSS-пикселях
if (typeof drawFn === 'function') drawFn();
}
}
```
Как интегрировать с событиями ресайза без зависаний
- Не делайте тяжёлый ререндер в обработчике window.onresize напрямую. Сформируйте изменение размера и рендер на следующем requestAnimationFrame.
- Используйте дебаунс или throttle. Лучше: при первом событии запускайте requestAnimationFrame, а все последующие события до следующего RAF игнорируйте.
- Можно использовать ResizeObserver на контейнере, но всё равно нужно дебаунс/RAF, потому что RO может выдавать много событий за секунду.
Пример обработки с RAF
```js
let rafId = null;
const canvas = document.querySelector('canvas');
function scheduleResizeAndDraw() {
if (rafId) return;
rafId = requestAnimationFrame(() => {
rafId = null;
resizeCanvasToDisplaySize(canvas, drawAll);
});
}
window.addEventListener('resize', scheduleResizeAndDraw);
new ResizeObserver(scheduleResizeAndDraw).observe(document.documentElement); // или контейнер
```
Оптимизации для плавности и предотвращения подвисаний
- Ограничьте dpr или итоговый размер, если canvas становится слишком большим (см. maxTexture). На больших мониторах и с высоким DPR буфер может превысить возможности GPU и тормозить. Можно масштабировать downsample: используя dprEffective = Math.min(dpr, 2) или динамически снижать во время ресайза.
- Если рендер очень тяжёлый — используйте OffscreenCanvas + WebWorker: в воркере делаете тяжёлую отрисовку в OffscreenCanvas, возвращаете ImageBitmap и отрисовываете на основном canvas. Это снимает нагрузку с UI‑потока.
- Во время интерактивного изменения размера (перетаскивание границы) можно рендерить временно в более низком разрешении (например 0.5 * dpr), а по окончании ресайза — перерисовать в полном качестве.
- Не считывайте layout-параметры (getBoundingClientRect, clientWidth) часто и не смешивайте чтение и запись DOM в одном цикле — это вызывает layout thrash. Читайте размеры один раз, затем изменяйте canvas и рендерьте.
- Если приложение “зависает” на некоторых направлениях изменения окна, вероятно вы либо создаёте рекурсивные/многочасовые перерисовки, либо создаёте очень большой буфер (выполняется OOM или GPU блокировка). Проверьте исходные размеры и console.logs, ставьте ограничение maxTexture.
Когда стоит применять OffscreenCanvas
- Если у вас очень сложная векторная/пиксельная сцена (много объектов) и перенос рендеринга в фон уменьшит лаг при ресайзе — используйте OffscreenCanvas в воркере. Тогда на основной странице вы просто drawImage(imageBitmap) — это быстро.
Контрольный список для отладки зависаний
- Логируйте вычисленные cssW/cssH и итоговые canvas.width/height при ресайзе. Если они внезапно становятся гигантскими — именно это вызывает проблему.
- Проверьте devicePixelRatio на устройствах (иногда меняется при перемещении окна на другой монитор).
- Проверьте максимальный размер текстуры GPU (16384, 32768 и т.д.) и лимитируйте.
- Убедитесь, что рендер происходит через RAF, а не синхронно внутри обработчика resize/RO.
- При подозрении на утечки/блокировку профилируйте main thread в DevTools.
Итого
- Делайте canvas.width/height = cssSize * devicePixelRatio, а CSS style.width/height = cssSize px;
- Устанавливайте контекстный transform (setTransform) под dpr;
- Дебаунсьте/используйте requestAnimationFrame при ресайзе; при тяжёлой отрисовке — offscreen/worker или рендер в пониженном разрешении во время ресайза;
- Ограничьте максимальные размеры, чтобы не превысить GPU/память.
Если хотите, пришлите ваш текущий код обработчика ресайза и функцию рендера — посмотрю конкретно и укажу, где вероятно затыки и как их исправить.