Предоставлять доступ к контенту canvas функциям браузера

Веб-функции: HTML в canvas

Обычный контент <canvas> не виден для таких функций браузера, как программы чтения с экрана, индексирование, инструменты перевода, специальные возможности, поиск на странице, печать и т. д. С помощью HTML in canvas вы можете отображать реальный DOM непосредственно в элементе canvas. Добавление атрибута content="drawable" в элемент HTML <canvas> позволяет разместить его дочерние элементы HTML, а атрибут drawable помечает элементы, которые можно отрисовать в контексте отрисовки холста. С его помощью можно оформлять и размещать текст на холсте, предоставлять доступ к контенту холста функциям браузера (например, специальным возможностям, переводу или поиску на странице) и применять 2D- и 3D-эффекты к HTML.

Рекомендации по внедрению

  1. Проверьте, поддерживается ли в браузере HTML-in-Canvas:

    if ('content' in HTMLCanvasElement.prototype) {
      // Use HTML in Canvas API
    } else {
      // Use fallback strategy
    }
    
  2. Добавьте атрибут content="drawable" в элемент HTML <canvas>.

  3. Разместите HTML-контент внутри элемента <canvas> и добавьте атрибут drawable в каждый элемент, который вы рисуете. Элемент с drawable захватывает свое поддерево, за исключением вложенных потомков, которые также являются drawable.

    <canvas id="canvas" content="drawable">
      <div id="html-content" drawable></div>
    </canvas>
    
  4. Чтобы избежать размытия, масштабируйте сетку холста в соответствии с коэффициентом масштабирования устройства:

    const observer = new ResizeObserver(([entry]) => {
      const dpc = entry.devicePixelContentBoxSize;
      canvas.width = dpc
        ? dpc[0].inlineSize
        : Math.round(entry.contentRect.width * window.devicePixelRatio);
      canvas.height = dpc
        ? dpc[0].blockSize
        : Math.round(entry.contentRect.height * window.devicePixelRatio);
    });
    
    const supportsDevicePixelContentBox =
      typeof ResizeObserverEntry !== "undefined" &&
      "devicePixelContentBoxSize" in ResizeObserverEntry.prototype;
    const options = supportsDevicePixelContentBox
      ? { box: "device-pixel-content-box" }
      : {};
    observer.observe(canvas, options);
    
  5. Отрисуйте HTML-контент на холсте в обработчике событий canvas.onpaint. Элемент можно нарисовать только после того, как он будет отрисован на холсте. Вызовите canvas.requestPaint(), чтобы запросить событие paint.

  • В 2D-контексте используйте метод drawElementImage. Она рисует последний снимок элемента drawable и его поддерево, начиная с поля границы элемента, до преобразований CSS:
canvas.onpaint = () => {
  ctx.reset();
  // Draw the form element at x:0, y:0
  ctx.drawElementImage(form_element, 0, 0);
};
  • В контекстах WebGL и WebGPU вы копируете элемент в текстуру. Размер текстуры: canvas.captureElementImage(element). Значения width и height указываются в пикселях сетки холста (резервного хранилища), поэтому округлите их с помощью Math.ceil(). captureElementImage() выдает ошибку, если у элемента ещё нет записи об отрисовке: вызовите requestPaint() и повторите попытку при следующем событии paint.
// Returns the element's size in canvas grid pixels, or null if the element
// has no paint record yet.
function getElementImageSize(canvas, element) {
  let elementImage;
  try {
    elementImage = canvas.captureElementImage(element);
  } catch (err) {
    canvas.requestPaint(); // No paint record yet: retry on the next paint event.
    return null;
  }
  const width = Math.ceil(elementImage.width);
  const height = Math.ceil(elementImage.height);
  elementImage.close();
  return width > 0 && height > 0 ? { width, height } : null;
}
  • В контексте WebGL сначала предварительно выделите подложку текстуры с помощью метода texImage2D. Фрагменты кода в этом руководстве используют контекст WebGL 2 (canvas.getContext("webgl2")). Выделите хранилище текстур, вызвав texImage2D() с null вместо данных пикселей. Это зарезервирует память графического процессора заданного размера и формата без загрузки пикселей. Следуйте этим правилам:
    • Задайте размер текстуры, как показано выше (captureElementImage()).
    • Выделяйте уровень 0 только при первой загрузке или при изменении размера, поскольку перераспределение очищает текстуру. Используйте изменяемое хранилище: неизменяемое хранилище texStorage2D() не может менять размер при изменении размера элемента.
// Binds the texture, and allocates its storage on the first upload, and when
// the element size changes. `state` stores the allocated size.
function allocateElementTexture(gl, texture, state, width, height) {
  gl.bindTexture(gl.TEXTURE_2D, texture);
  if (state.width === width && state.height === height) return;

  gl.texImage2D(
    gl.TEXTURE_2D,
    0,                // Mipmap level
    gl.RGBA8,         // Internal format: 8 bits per RGBA channel
    width,            // Size in canvas grid pixels
    height,
    0,                // Border: must be 0
    gl.RGBA,          // Format and type that match RGBA8
    gl.UNSIGNED_BYTE,
    null              // null reserves GPU memory without uploading pixels
  );
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
  state.width = width;
  state.height = height;
}

const texture = gl.createTexture();
const textureState = { width: 0, height: 0 };
  • Затем в контексте WebGL отрисуйте HTML-контент в текстуре с помощью метода texElementSubImage2D. Следуйте этим правилам:
    • Передайте { width, height } в конфигурации. В результате копия заполняет выделенную текстуру независимо от масштаба холста.
    • Если размер изменился, перераспределите текстуру, как показано выше.
// Uploads the element into the texture. Returns false if the element has no
// paint record yet. In that case, it's uploaded on the next paint event.
function uploadElementWebGL(gl, canvas, texture, element, state) {
  const size = getElementImageSize(canvas, element);
  if (!size) return false;
  const { width, height } = size;

  // Bind the texture, and reallocate it if the size changed (see above).
  allocateElementTexture(gl, texture, state, width, height);
  gl.texElementSubImage2D(gl.TEXTURE_2D, 0, 0, 0, element, { width, height });
  return true;
}

canvas.onpaint = () => {
  try {
    uploadElementWebGL(gl, canvas, texture, uiElement, textureState);
  } catch (err) {
    console.error('texElementSubImage2D copy failed:', err);
  }
};
  • В контексте WebGPU используйте метод drawElementImageToTexture. Следуйте этим правилам:
    • Создайте текстуру с использованием COPY_DST и RENDER_ATTACHMENT и добавьте TEXTURE_BINDING, чтобы использовать ее в шейдерах. Неправильное использование регистрируется как ошибка проверки WebGPU, а не как исключение.
    • Передайте размер текстуры как size. Без size более крупный элемент обрезается по размеру текстуры. Если задано значение size, элемент масштабируется, чтобы заполнить контейнер.
    • Размер текстур WebGPU нельзя изменить. Повторно создайте текстуру при изменении размера элемента и перестройте группы привязки, которые на нее ссылаются.
// WebGPU textures can't be resized. Create the texture once, and recreate it
// when the element size changes.
function ensureElementTexture(device, state, width, height) {
  if (state.texture?.width === width && state.texture?.height === height) {
    return state.texture;
  }
  state.texture?.destroy();
  state.texture = device.createTexture({
    size: { width, height },
    format: "rgba8unorm",
    usage:
      GPUTextureUsage.TEXTURE_BINDING |
      GPUTextureUsage.COPY_DST |
      GPUTextureUsage.RENDER_ATTACHMENT,
  });
  return state.texture; // Recreate the bind groups that used the previous texture.
}

const textureState = { texture: null };

canvas.onpaint = () => {
  const size = getElementImageSize(canvas, uiElement);
  if (!size) return;
  const previousTexture = textureState.texture;
  const texture = ensureElementTexture(device, textureState, size.width, size.height);
  if (texture !== previousTexture) {
    // New texture: create, or rebuild, the bind groups that reference it.
  }
  try {
    device.queue.drawElementImageToTexture({ source: uiElement }, { texture, size });
  } catch (err) {
    console.error('drawElementImageToTexture copy failed:', err);
  }
};

Если для отрисовки сцены используется цикл requestAnimationFrame, вызовите в нем canvas.requestPaint(), чтобы HTML-контент отрисовывался на холсте. event.changedElements – список элементов, отрисовка которых изменилась. Перерисовывать элемент или повторно загружать его текстуру нужно только в том случае, если дочерние элементы HTML были обновлены.

function render() {
  // Request to update the canvas
  canvas.requestPaint();
  requestAnimationFrame(render);
}
requestAnimationFrame(render);

canvas.onpaint = (event) => {
  if (event.changedElements && event.changedElements.length > 0) {
    // Redraw the element, or update its texture, as shown above
  }
  // In WebGL and WebGPU, render the scene, and sync the element geometry as shown in step 6
};
  1. Синхронизируйте геометрию элемента.

Браузеру нужно знать, где отрисовывается элемент HTML, чтобы его положение в DOM соответствовало отрисованным пикселям. Проверка нажатия, например для кликов и выделения текста, и специальные возможности используют эту геометрию: элемент drawable, геометрия которого не обновлена, по-прежнему отображается в дереве специальных возможностей, но без информации о геометрии. Проверка нажатия на холсте выполняется только для элементов, нарисованных с помощью метода 2D drawElementImage() или зарегистрированных с помощью updateElementGeometry().

  • В случае с 2D-контекстом браузер синхронизирует элемент автоматически. drawElementImage() выравнивает границы проверки нажатия DOM и кольца фокуса программы чтения с экрана по нарисованным координатам, поэтому вам не нужно устанавливать style.transform. Чтобы управлять геометрией вручную, например когда вы рисуете один и тот же элемент несколько раз, передайте { preserveElementGeometry: true } в drawElementImage() и примените преобразования с помощью canvas.updateElementGeometry().

  • В случае с 3D-объектами, созданными с помощью WebGL или WebGPU, холст не может определить, где нарисован элемент HTML, поэтому вам нужно указать это. Вычислите DOMMatrix, который сопоставляет рамку элемента в пикселях CSS с холстом в пикселях CSS. Браузеру необходимо сопоставить трехмерное пространство координат с пространством координат CSS, используя преобразование области просмотра. Для этого выполните следующие действия:

    • Нормализуйте элемент HTML. Размеры элементов HTML указываются в пикселях (например, ширина 200 пикселей). Однако WebGL обычно рассматривает объекты как "единичные квадраты", например, в диапазоне от -0,5 до 0, 5. Если вы не нормализуете значения, кнопка размером 200 пикселей будет выглядеть в 200 раз больше. На этом шаге также переворачивается ось Y, поскольку в CSS положительным является направление вниз, а в WebGL – вверх.
    • Преобразуйте матрицу MVP WebGL в матрицу DOM.
    • Сопоставьте с областью просмотра холста. Этот шаг представляет собой этап "масштабирования": он растягивает математические вычисления единичного пространства обратно, чтобы они соответствовали размерам пикселей CSS вашего элемента <canvas> на экране, и переворачивает ось Y обратно.
    • Рассчитайте окончательное преобразование. Умножьте матрицы в следующем порядке: Viewport * MVP * Normalization. Объединив их в одно окончательное преобразование, мы получим "карту", которая точно указывает браузеру, где должен находиться этот элемент HTML, чтобы он совпадал с 3D-рисунком. Браузер выполняет деление перспективы.
    // Maps the element's border box (CSS pixels) to the canvas (CSS pixels), for a
    // quad that spans -0.5 to 0.5 in model space and is drawn with the `mvp` matrix.
    function computeCanvasTransform(canvas, element, mvp) {
      const width = element.offsetWidth;
      const height = element.offsetHeight;
    
      // 1. Normalize the HTML element (CSS pixels -> WebGL Model Space)
      const toGLModel = new DOMMatrix()
        // Scale pixels to 1 unit, flip Y (as in CSS it points down, and in WebGL it points up)
        .scale(1 / width, -1 / height, 1 / height)
        // Center the origin: (0,0) becomes (-width/2, -height/2) before scaling
        .translate(-width / 2, -height / 2);
    
      // 2. Convert WebGL MVP Matrix to DOM Matrix
      const mvpDOM = new DOMMatrix(Array.from(mvp));
    
      // 3. Map to the canvas viewport, in CSS pixels
      const clipToCanvasViewport = new DOMMatrix()
        // Move center (0,0) to center of canvas
        .translate(canvas.clientWidth / 2, canvas.clientHeight / 2)
        // Scale normalized clip (-1..1) to viewport size, and flip Y back
        .scale(canvas.clientWidth / 2, -canvas.clientHeight / 2, canvas.clientHeight / 2);
    
      // 4. Multiply: (Clip -> Pixels) * (MVP) * (pixels -> unit square)
      return clipToCanvasViewport.multiply(mvpDOM).multiply(toGLModel);
    }
    

Затем передайте преобразование в canvas.updateElementGeometry() как canvasTransform. В результате элемент HTML будет размещен непосредственно над отрисованными пикселями. Это гарантирует, что когда пользователь нажимает кнопку или выбирает текст, он действительно взаимодействует с реальным элементом HTML. Браузер также использует преобразование для границ специальных возможностей элемента, например для колец фокусировки программы чтения с экрана:

  canvas.updateElementGeometry(targetHTMLElement, {
    canvasTransform: computeCanvasTransform(canvas, targetHTMLElement, htmlElementMVP),
  });

Для управления проверкой нажатия используйте следующие параметры:

  • Каждый вызов updateElementGeometry() перемещает элемент в начало порядка проверки нажатия. Передайте мяч игроку { preserveHitTestOrder: true }, чтобы сохранить позицию.
  • Вызовите canvas.clearElementGeometry(element), чтобы удалить элемент из проверки нажатия, например когда вы его скрываете.

Пример кода

2D Canvas

<canvas id="canvas" content="drawable" style="width: 400px; height: 200px;">
  <div id="ui-element" drawable>
    <p>
      This text is rendered inside the canvas but is present in the DOM tree.
    </p>
    <input type="email" name="email" placeholder="enter your email" />
    <button type="button">Submit</button>
  </div>
</canvas>

<script>
  const canvas = document.getElementById("canvas");
  const ctx = canvas.getContext("2d");
  const uiElement = document.getElementById("ui-element");

  canvas.onpaint = () => {
    ctx.reset();
    // Draw the HTML element at x:0, y:0. The browser syncs the DOM location
    // with the drawn location.
    ctx.drawElementImage(uiElement, 0, 0);
  };

  // Handle resizing to match device pixels
  const observer = new ResizeObserver(([entry]) => {
    const dpc = entry.devicePixelContentBoxSize;
    canvas.width = dpc
      ? dpc[0].inlineSize
      : Math.round(entry.contentRect.width * window.devicePixelRatio);
    canvas.height = dpc
      ? dpc[0].blockSize
      : Math.round(entry.contentRect.height * window.devicePixelRatio);
    canvas.requestPaint();
  });

  const supportsDevicePixelContentBox =
    typeof ResizeObserverEntry !== "undefined" &&
    "devicePixelContentBoxSize" in ResizeObserverEntry.prototype;
  const options = supportsDevicePixelContentBox
    ? { box: "device-pixel-content-box" }
    : {};
  observer.observe(canvas, options);
</script>

Холст WebGL

<canvas id="canvas" content="drawable" style="width: 400px; height: 400px;">
  <div id="ui-element" drawable>
    <p>WebGL UI Element</p>
    <button>Action</button>
  </div>
</canvas>

<script>
  const canvas = document.getElementById("canvas");
  const gl = canvas.getContext("webgl2");
  const uiElement = document.getElementById("ui-element");

  // getElementImageSize(), allocateElementTexture(), and uploadElementWebGL()
  // are defined in step 5.
  // computeCanvasTransform() is defined in step 6.

  // Setup WebGL texture...
  const texture = gl.createTexture();
  gl.bindTexture(gl.TEXTURE_2D, texture);
  const textureState = { width: 0, height: 0 };

  canvas.onpaint = () => {
    // 1. Update texture with HTML content
    try {
      uploadElementWebGL(gl, canvas, texture, uiElement, textureState);
    } catch (err) {
      console.error('texElementSubImage2D copy failed:', err);
    }

    // ... Render your 3D scene here, calculating htmlElementMVP matrix ...

    // 2. Sync DOM position, hit testing, and accessibility bounds with 3D scene
    canvas.updateElementGeometry(uiElement, {
      canvasTransform: computeCanvasTransform(canvas, uiElement, htmlElementMVP),
    });
  };
</script>

Холст WebGPU

<canvas id="canvas" content="drawable" style="width: 400px; height: 400px;">
  <div id="ui-element" drawable>
    <p>WebGPU UI Element</p>
  </div>
</canvas>

<script>
  const canvas = document.getElementById("canvas");
  const context = canvas.getContext("webgpu");
  const uiElement = document.getElementById("ui-element");

  // getElementImageSize() and ensureElementTexture() are defined in step 5.
  // computeCanvasTransform() is defined in step 6.

  // Setup WebGPU...
  // const device = ...
  const textureState = { texture: null };

  canvas.onpaint = () => {
    // 1. Copy HTML content to a texture that matches the element size
    const size = getElementImageSize(canvas, uiElement);
    if (size) {
      const previousTexture = textureState.texture;
      const texture = ensureElementTexture(device, textureState, size.width, size.height);
      if (texture !== previousTexture) {
        // New texture: create, or rebuild, the bind groups that reference it.
      }
      try {
        device.queue.drawElementImageToTexture({ source: uiElement }, { texture, size });
      } catch (err) {
        console.error('drawElementImageToTexture copy failed:', err);
      }
    }

    // ... Render your 3D scene here, calculating htmlElementMVP matrix ...

    // 2. Sync DOM position, hit testing, and accessibility bounds (same matrix math as WebGL)
    canvas.updateElementGeometry(uiElement, {
      canvasTransform: computeCanvasTransform(canvas, uiElement, htmlElementMVP),
    });
  };
</script>

Рекомендации

  • ОБЯЗАТЕЛЬНО. Прежде чем использовать HTML-in-Canvas API, проверьте, поддерживается ли он в браузере.
  • ОБЯЗАТЕЛЬНО. Всегда добавляйте атрибут content="drawable" к элементу <canvas>.
  • ОБЯЗАТЕЛЬНО. Добавьте атрибут drawable ко всем элементам, которые вы рисуете, включая дочерние элементы холста.
  • ОБЯЗАТЕЛЬНО. Используйте обработчик событий onpaint, чтобы отрисовать HTML-контент на холсте.
  • ОБЯЗАТЕЛЬНО. Используйте метод drawElementImage в 2D или drawElementImageToTexture в WebGPU, чтобы отрисовать HTML-контент на холсте.
  • ОБЯЗАТЕЛЬНО. В WebGL предварительно выделите буфер текстуры с помощью texImage2D() один раз (при инициализации или изменении размера элемента). Перераспределяйте текстуру, только когда меняется размер, и задайте для TEXTURE_MIN_FILTER значение LINEAR.
  • ОБЯЗАТЕЛЬНО. В WebGL используйте метод texElementSubImage2D, чтобы преобразовать HTML-контент в текстуру, которую вы предварительно выделили с помощью texImage2D(), размер которой определяется captureElementImage().
  • ОБЯЗАТЕЛЬНО. В WebGPU задайте размер текстуры из captureElementImage() и воссоздавайте ее только при изменении размера. Создайте текстуру с использованием COPY_DST и RENDER_ATTACHMENT и передайте размер текстуры как size.
  • ОБЯЗАТЕЛЬНО. Синхронизируйте геометрию элемента HTML с отрисованным контентом. В 2D-режиме drawElementImage делает это автоматически. В WebGL и WebGPU вызовите canvas.updateElementGeometry(element, { canvasTransform }).
  • ОБЯЗАТЕЛЬНО. Используйте ResizeObserver, чтобы определить размер экрана и изменить размер холста в соответствии с пикселями устройства.
  • НЕ встраивайте в холст контент из разных источников, так как это не поддерживается.
  • НЕ инициализируйте ResizeObserver в обработчике событий onpaint, так как это может привести к утечке памяти.

Резервные стратегии

HTML-in-Canvas API пока поддерживается не всеми современными браузерами, поэтому обычно требуется резервная стратегия.

Однако, учитывая преимущества этого API, HTML-in-Canvas следует использовать, если браузер его поддерживает.

Резервная стратегия зависит от сценария использования. Например, если интерактивный HTML-контент в элементе canvas не поддерживается, разместите его поверх этого элемента с помощью CSS.

Полифилы HTML-in-Canvas

Чтобы имитировать HTML-in-Canvas API в браузерах, которые его не поддерживают, используйте следующий полифил.

  1. Установите или встройте библиотеку:

    # Install
    npm install three-html-render
    
    # Embed
    <script src="https://cdn.jsdelivr.net/npm/three-html-render/dist/polyfill.js"></script>
    
  2. Выполните метод installHtmlInCanvasPolyfill(), чтобы перевести HTML-код в Canvas.