Вот что вам нужно знать, чтобы изменить размер canvas.
У каждого canvas есть 2 размера. Размер его drawingbuffer. Это сколько пикселей в canvas. Второй размер - это размер, в котором отображается canvas. CSS определяет размер, в котором canvas отображается.
Вы можете установить размер drawingbuffer canvas двумя способами. Один используя HTML
<canvas id="c" width="400" height="300"></canvas>
Другой используя JavaScript
<canvas id="c"></canvas>
JavaScript
const canvas = document.querySelector("#c");
canvas.width = 400;
canvas.height = 300;
Что касается установки размера отображения canvas, если у вас нет CSS, который влияет на размер отображения canvas, размер отображения будет таким же, как размер его drawingbuffer. Поэтому в 2 примерах выше drawingbuffer canvas составляет 400x300, и его размер отображения также 400x300.
Вот пример canvas, чей drawingbuffer составляет 10x15 пикселей, который отображается 400x300 пикселей на странице
<canvas id="c" width="10" height="15" style="width: 400px; height: 300px;"></canvas>
или, например, так
<style>
#c {
width: 400px;
height: 300px;
}
</style>
<canvas id="c" width="10" height="15"></canvas>
Если мы нарисуем одну пиксельную вращающуюся линию в этот canvas, мы увидим что-то вроде этого
Почему это так размыто? Потому что браузер берет наш 10x15 пиксельный canvas и растягивает его до 400x300 пикселей, и обычно фильтрует его при растягивании.
Итак, что мы делаем, если, например, хотим, чтобы canvas заполнил окно? Ну, сначала мы можем заставить браузер растянуть canvas, чтобы заполнить окно с помощью CSS. Пример
<html>
<head>
<style>
/* */
html, body {
height: 100%;
margin: 0;
}
/* делаем canvas заполняющим его контейнер */
#c {
width: 100%;
height: 100%;
display: block;
}
</style>
</head>
<body>
<canvas id="c"></canvas>
</body>
</html>
Теперь нам просто нужно сделать drawingbuffer соответствующим любому размеру, до которого браузер растянул canvas. Это, к сожалению, сложная тема. Давайте рассмотрим некоторые различные методы
clientWidth
и clientHeight
Это самый простой способ.
clientWidth
и clientHeight
- это свойства, которые есть у каждого элемента в HTML, которые говорят нам
размер элемента в CSS пикселях.
Примечание: Client rect включает любой CSS padding, поэтому если вы используете
clientWidth
и/илиclientHeight
, лучше не ставить никакой padding на ваш canvas элемент.
Используя JavaScript, мы можем проверить, какого размера этот элемент отображается, а затем настроить размер его drawingbuffer, чтобы соответствовать.
function resizeCanvasToDisplaySize(canvas) {
// Ищем размер, в котором браузер отображает canvas в CSS пикселях.
const displayWidth = canvas.clientWidth;
const displayHeight = canvas.clientHeight;
// Проверяем, не является ли canvas того же размера.
const needResize = canvas.width !== displayWidth ||
canvas.height !== displayHeight;
if (needResize) {
// Делаем canvas того же размера
canvas.width = displayWidth;
canvas.height = displayHeight;
}
return needResize;
}
Давайте вызовем эту функцию прямо перед рендерингом, чтобы она всегда настраивала canvas до нашего желаемого размера прямо перед рисованием.
function drawScene() {
resizeCanvasToDisplaySize(gl.canvas);
...
И вот это
Эй, что-то не так? Почему линия не покрывает всю область?
Причина в том, что когда мы изменяем размер canvas, нам также нужно вызвать gl.viewport
, чтобы установить viewport.
gl.viewport
говорит WebGL, как конвертировать из пространства отсечения (-1 до +1) обратно в пиксели и где это делать
внутри canvas. Когда вы впервые создаете WebGL контекст, WebGL установит viewport, чтобы соответствовать размеру
canvas, но после этого вам нужно установить его. Если вы изменяете размер canvas,
вам нужно сказать WebGL новую настройку viewport.
Давайте изменим код, чтобы обработать это. Помимо этого, поскольку WebGL контекст имеет ссылку на canvas, давайте передадим это в resize.
function drawScene() {
resizeCanvasToDisplaySize(gl.canvas);
+ gl.viewport(0, 0, gl.canvas.width, gl.canvas.height);
...
Теперь это работает.
Откройте это в отдельном окне, измените размер окна, обратите внимание, что оно всегда заполняет окно.
Я слышу, как вы спрашиваете, почему WebGL не устанавливает viewport автоматически для нас, когда мы изменяем размер canvas? Причина в том, что он не знает, как или почему вы используете viewport. Вы могли бы рендерить в framebuffer или делать что-то еще, что требует другого размера viewport. WebGL не имеет способа узнать ваше намерение, поэтому он не может автоматически установить viewport для вас.
devicePixelRatio
и масштабированияПочему это не конец? Ну, здесь становится сложно.
Первое, что нужно понять, это то, что большинство размеров в браузере в CSS пиксельных единицах. Это попытка сделать размеры независимыми от устройства. Так, например, в начале этой статьи мы установили размер отображения canvas в 400x300 CSS пикселей. В зависимости от того, есть ли у пользователя HD-DPI дисплей, или он увеличен или уменьшен, или имеет установленный уровень масштабирования ОС, сколько фактических пикселей это станет на мониторе, будет разным.
window.devicePixelRatio
скажет нам в общем соотношение фактических пикселей
к CSS пикселям на вашем мониторе. Например, вот текущая настройка вашего браузера
devicePixelRatio =
Если вы на настольном компьютере или ноутбуке, попробуйте нажать ctrl++ и ctrl+-, чтобы увеличить и уменьшить (⌘++ и ⌘+- на Mac). Вы должны увидеть, как число изменяется.
Итак, если мы хотим, чтобы количество пикселей в canvas соответствовало количеству пикселей, фактически используемых для его отображения,
казалось бы очевидным решением было бы умножить clientWidth
и clientHeight
на devicePixelRatio
, как это:
function resizeCanvasToDisplaySize(canvas) {
// Ищем размер, в котором браузер отображает canvas в CSS пикселях.
const dpr = window.devicePixelRatio;
const displayWidth = Math.round(canvas.clientWidth * dpr);
const displayHeight = Math.round(canvas.clientHeight * dpr);
// Проверяем, не является ли canvas того же размера.
const needResize = canvas.width != displayWidth ||
canvas.height != displayHeight;
if (needResize) {
// Делаем canvas того же размера
canvas.width = displayWidth;
canvas.height = displayHeight;
}
return needResize;
}
Нам нужно вызвать Math.round
(или Math.ceil
, или Math.floor
или | 0
), чтобы получить число
к целому, потому что canvas.width
и canvas.height
всегда в целых числах, поэтому
наше сравнение может не сработать, если devicePixelRatio
не является целым числом, что часто встречается, особенно
если пользователь масштабирует.
Примечание: Использовать ли
Math.floor
илиMath.ceil
илиMath.round
не определено HTML спецификацией. Это зависит от браузера. 🙄
В любом случае, это не будет работать на самом деле. Новая проблема в том, что при devicePixelRatio
, который не равен 1.0,
CSS размер, который нужен canvas, чтобы заполнить данную область, может не быть целым значением,
но clientWidth
и clientHeight
определены как целые числа. Допустим, окно
999 фактических пикселей устройства в ширину, ваш devicePixelRatio = 2.0, и вы просите canvas размером 100%.
Нет целого CSS размера * 2.0, который = 999.
Следующее решение - использовать
getBoundingClientRect()
.
Он возвращает DOMRect
,
который имеет width
и height
. Это тот же
client rect, который представлен clientWidth
и clientHeight
, но он не обязан
быть целым числом.
Ниже фиолетовый <canvas>
, который установлен на width: 100%
его контейнера. Уменьшите масштаб несколько раз до 75% или 60%
и вы можете увидеть, как его clientWidth
и его getBoundingClientRect().width
расходятся.
На моих машинах я получаю эти показания
Windows 10, уровень масштабирования 75%, Chrome
clientWidth: 700
getBoundingClientRect().width = 700.0000610351562
MacOS, уровень масштабирования 90%, Chrome
clientWidth: 700
getBoundingClientRect().width = 700.0000610351562
MacOS, уровень масштабирования -1, Safari (safari не показывает уровень масштабирования)
clientWidth: 700
getBoundingClientRect().width = 699.9999389648438
Firefox, и Windows, и MacOS все уровни масштабирования
clientWidth: 700
getBoundingClientRect().width = 700
Примечание: Firefox показал 700 в этой конкретной настройке, но при достаточном количестве различных тестов я
видел, что он дает нецелый результат из getBoundingClientRect
, например, сделайте окно
тонким, чтобы 100% canvas был меньше 700, и вы можете получить нецелый результат
в Firefox.
Итак, учитывая это, мы могли бы попробовать использовать getBoundingClientRect
.
function resizeCanvasToDisplaySize(canvas) {
// Ищем размер, в котором браузер отображает canvas в CSS пикселях.
const dpr = window.devicePixelRatio;
const {width, height} = canvas.getBoundingClientRect();
const displayWidth = Math.round(width * dpr);
const displayHeight = Math.round(height * dpr);
// Проверяем, не является ли canvas того же размера.
const needResize = canvas.width != displayWidth ||
canvas.height != displayHeight;
if (needResize) {
// Делаем canvas того же размера
canvas.width = displayWidth;
canvas.height = displayHeight;
}
return needResize;
}
Итак, мы закончили? К сожалению, нет. Оказывается, что canvas.getBoundingClientRect()
может
не всегда возвращать точно правильный размер. Причина сложная, но она связана с
тем, как браузер решает рисовать вещи. Некоторые части решаются на уровне HTML,
а некоторые части решаются позже на уровне “композитора” (часть, которая фактически рисует).
getBoundingClientRect()
происходит на уровне HTML, но определенные вещи происходят после этого,
что может повлиять на то, какого размера canvas фактически рисуется.
Я думаю, пример в том, что HTML часть работает в абстрактном, а композитор
работает в конкретном. Итак, допустим, у вас есть окно шириной 999 пикселей устройства
и devicePixelRatio 2.0. Вы делаете два элемента рядом, которые
width: 50%
. Итак, HTML вычисляет, что каждый должен быть 499.5 пикселей устройства. Но когда
фактически приходит время рисовать, композитор не может нарисовать 499.5 пикселей, поэтому один
элемент получает 499, а другой получает 500. Какой получает или теряет пиксель,
не определено никакими спецификациями.
Решение, которое придумали производители браузеров, - использовать
ResizeObserver
API
и предоставить фактический используемый размер через свойство devicePixelContextBoxSize
записей, которые он предоставляет.
Он возвращает фактическое количество пикселей устройства, которые были использованы. Обратите внимание, что это называется
ContentBox
, а не ClientBox
, что означает, что это фактическая часть
canvas элемента, показывающая содержимое canvas, поэтому она не включает padding, как
clientWidth
, clientHeight
и getBoundingClientRect
, приятное преимущество.
Это возвращается таким образом, потому что результат асинхронный. “Композитор”, упомянутый выше, работает асинхронно от страницы. Он может выяснить размер, который он фактически собирается использовать, а затем отправить вам этот размер вне полосы.
К сожалению, хотя ResizeObserver
доступен во всех современных браузерах,
devicePixelContentBoxSize
пока доступен только в Chrome/Edge. Вот как
его использовать.
Мы создаем ResizeObserver
и передаем ему функцию для вызова в любое время, когда любой элемент,
который мы наблюдаем, изменяет размер. В нашем случае это наш canvas.
const resizeObserver = new ResizeObserver(onResize);
resizeObserver.observe(canvas, {box: 'content-box'});
Код выше создает ResizeObserver
, который будет вызывать функцию onResize
(ниже), когда элемент, который мы наблюдаем, изменяет размер. Мы говорим ему observe
наш
canvas. Мы говорим ему наблюдать, когда content-box
изменяет размер. Это
важно и немного запутанно. Мы могли бы попросить его сказать нам, когда
device-pixel-content-box
изменяет размер, но давайте представим, что у нас есть canvas, который
имеет какой-то процентный размер окна, как обычные 100% нашего примера с линией
выше. В этом случае наш canvas всегда будет иметь то же количество пикселей устройства
независимо от уровня масштабирования. Окно не изменило размер, когда мы масштабируем, поэтому все еще
то же количество пикселей устройства. С другой стороны, content-box
будет
изменяться, когда мы масштабируем, потому что он измеряется в CSS пикселях, и поэтому, когда мы масштабируем, больше или
меньше CSS пикселей помещается в количество пикселей устройства.
Если нас не волнует уровень масштабирования, то мы могли бы просто наблюдать device-pixel-content-box
.
Это выбросит ошибку, если это не поддерживается, поэтому мы сделали бы что-то вроде этого
const resizeObserver = new ResizeObserver(onResize);
try {
// вызывать нас только если количество пикселей устройства изменилось
resizeObserver.observe(canvas, {box: 'device-pixel-content-box'});
} catch (ex) {
// device-pixel-content-box не поддерживается, поэтому откатываемся к этому
resizeObserver.observe(canvas, {box: 'content-box'});
}
Функция onResize
будет вызвана с массивом ResizeObserverEntry
. Один для каждой вещи,
которая изменила размер. Мы запишем размер в карту, чтобы мы могли обработать больше чем
один элемент.
// инициализируем с размером canvas по умолчанию
const canvasToDisplaySizeMap = new Map([[canvas, [300, 150]]]);
function onResize(entries) {
for (const entry of entries) {
let width;
let height;
let dpr = window.devicePixelRatio;
if (entry.devicePixelContentBoxSize) {
// ПРИМЕЧАНИЕ: Только этот путь дает правильный ответ
// Другие пути - несовершенные откаты
// для браузеров, которые не предоставляют никакого способа сделать это
width = entry.devicePixelContentBoxSize[0].inlineSize;
height = entry.devicePixelContentBoxSize[0].blockSize;
dpr = 1; // это уже в width и height
} else if (entry.contentBoxSize) {
if (entry.contentBoxSize[0]) {
width = entry.contentBoxSize[0].inlineSize;
height = entry.contentBoxSize[0].blockSize;
} else {
width = entry.contentBoxSize.inlineSize;
height = entry.contentBoxSize.blockSize;
}
} else {
width = entry.contentRect.width;
height = entry.contentRect.height;
}
const displayWidth = Math.round(width * dpr);
const displayHeight = Math.round(height * dpr);
canvasToDisplaySizeMap.set(entry.target, [displayWidth, displayHeight]);
}
}
Это своего рода беспорядок. Вы можете видеть, что API отправил по крайней мере 3 разные версии
перед поддержкой devicePixelContentBoxSize
😂
Теперь мы изменим нашу функцию resize, чтобы использовать эти данные
function resizeCanvasToDisplaySize(canvas) {
// Получаем размер, в котором браузер отображает canvas в пикселях устройства.
const [displayWidth, displayHeight] = canvasToDisplaySizeMap.get(canvas);
// Проверяем, не является ли canvas того же размера.
const needResize = canvas.width != displayWidth ||
canvas.height != displayHeight;
if (needResize) {
// Делаем canvas того же размера
canvas.width = displayWidth;
canvas.height = displayHeight;
}
return needResize;
}
Вот пример использования этого кода
Может быть трудно увидеть какую-либо разницу. Если у вас есть HD-DPI дисплей, как ваш смартфон или все Mac с 2019 года, или, возможно, 4k монитор, то эта линия должна быть тоньше, чем линия предыдущего примера.
В противном случае, если вы увеличиваете масштаб (я предлагаю открыть пример в новом окне), когда вы увеличиваете масштаб,
линия должна оставаться того же разрешения, тогда как если вы увеличиваете масштаб в предыдущем примере,
линия станет толще и с более низким разрешением, поскольку она не настраивается на devicePixelRatio
.
Просто как тест, вот все 3 метода выше, используя простой canvas 2d. Чтобы было просто, он не использует WebGL. Вместо этого он использует Canvas 2D и делает 2 паттерна, 2x2 пиксельный вертикальный черно-белый паттерн и 2x2 пиксельный горизонтальный черно-белый паттерн. Он рисует горизонтальный паттерн ▤ слева и вертикальный паттерн ▥ справа.
Измените размер этого окна, или лучше, откройте его в новом окне и увеличивайте и уменьшайте масштаб, используя
клавиши, упомянутые выше. На разных уровнях масштабирования измените размер окна и обратите внимание,
что только нижний работает во всех случаях (в Chrome/Edge). Обратите внимание, что чем выше
devicePixelRatio
вашего устройства, тем труднее может быть увидеть проблемы. Что вы
должны увидеть - это неизменяющийся паттерн слева и справа. Если вы видите резкие
паттерны или вы видите различающуюся темноту, как градиент, то это не работает.
Поскольку это будет работать только в Chrome/Edge, вам нужно попробовать это там, чтобы увидеть, как это работает.
Также обратите внимание, что некоторые ОС (MacOS) предоставляют опцию масштабирования на уровне ОС, которая в основном скрыта от приложений. В этом случае вы увидите легкий паттерн в нижнем примере (предполагая, что вы в Chrome/Edge), но это будет регулярный паттерн.
Это поднимает проблему, что нет хорошего решения в других браузерах, но нужен ли вам
настоящее решение? Большинство WebGL приложений делают что-то вроде рисования некоторых вещей в 3D
с текстурами и/или освещением на них. Как таковые, часто не заметно, использовать ли
верхнее решение, где мы игнорировали devicePixelRatio
, или использовать clientWidth
, clientHeight
или getBoundingClientRect()
* devicePixelRatio
и не беспокоиться об этом дальше.
Кроме того, слепое использование devicePixelRatio
может действительно замедлить вашу производительность.
На iPhoneX или iPhone11 window.devicePixelRatio
равен 3
, что
означает, что вы будете рисовать в 9 раз больше пикселей. На Samsung Galaxy S8 это значение 4
, что означает, что вы будете рисовать
в 16 раз больше пикселей. Это может действительно замедлить вашу программу. Фактически, это обычная оптимизация в играх - фактически рендерить
меньше пикселей, чем отображается, и позволить GPU масштабировать их вверх. Это действительно зависит от ваших потребностей. Если вы рисуете
график для печати, вы можете захотеть поддержать HD-DPI. Если вы делаете игру, вы можете не хотеть или можете захотеть дать
пользователю возможность включить или выключить поддержку, если их система не достаточно быстра, чтобы рисовать так много пикселей.
Еще одна оговорка заключается в том, что по крайней мере в январе 2021 года round(getBoundingClientRect * devicePixelRatio)
работает во всех современных браузерах ЕСЛИ И ТОЛЬКО ЕСЛИ canvas имеет полный
размер окна, как в примере с линией выше. Вот пример использования паттернов
Вы заметите, что если вы масштабируете и изменяете размер этой страницы, это не сработает с getBoundingClientRect
.
Это потому, что canvas не является полным окном, он находится в iframe. Откройте пример
в отдельном окне, и это будет работать.
Какое решение вы используете, зависит от вас. Для меня 99% времени я не использую
devicePixelRatio
. Это делает мои страницы медленными, и кроме нескольких графических профессионалов большинство
людей не заметят разницы. На этом сайте есть несколько диаграмм, где это используется, но большинство примеров не используют.
Если вы посмотрите на многие WebGL программы, они обрабатывают изменение размера или установку размера canvas многими различными способами. Я думаю, что спорно, что лучший способ - позволить браузеру выбрать размер для отображения canvas с помощью CSS, а затем посмотреть, какой размер он выбрал, и настроить количество пикселей в canvas в ответ. Если вам любопытно, вот некоторые из причин, по которым я думаю, что описанный выше способ является предпочтительным.