HTML 콘텐츠에 WebGL 셰이더 적용

웹 기능: HTML-in-Canvas

WebGL 셰이더는 강력한 GPU 가속 시각 효과를 제공하여 동적 물결 왜곡, 조명 모델, 색상 그레이딩, 맞춤 꼭짓점 변환과 같은 고급 기능을 지원합니다. HTML-in-Canvas API를 사용하면 개발자가 HTML 콘텐츠에 WebGL 텍스처를 적용할 수 있습니다. 이를 통해 기본 접근성, 텍스트 선택, DOM 이벤트 처리를 유지하면서 버튼, 입력란, 서식 있는 텍스트와 같은 완전한 대화형 UI 구성요소에 고성능 프래그먼트 및 꼭짓점 셰이더를 기본적으로 적용할 수 있습니다.

구현 방법

  1. 브라우저에서 HTML-in-Canvas가 지원되는지 확인합니다.

    if ('content' in HTMLCanvasElement.prototype) {
      // Use HTML in Canvas API
    } else {
      // Use fallback strategy
    }
    
  2. <canvas> HTML 요소에 content="drawable" 속성을 추가합니다.

  3. <canvas> 요소 내에 HTML 콘텐츠를 배치하고 그리는 모든 요소에 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. 먼저 texImage2D 메서드를 사용하여 텍스처 백업을 미리 할당합니다. 이 가이드의 스니펫은 WebGL 2 컨텍스트 (canvas.getContext("webgl2"))를 사용합니다.

픽셀 데이터를 업로드하지 않고 지정된 크기와 형식의 GPU 메모리를 예약하는 null를 사용하여 texImage2D()를 호출하여 텍스처 스토리지를 먼저 할당합니다. 다음과 같은 규칙을 따르세요.

  • canvas.captureElementImage(element)에서 텍스처 크기를 조정합니다. width 및 height는 캔버스 그리드 (백업 저장소) 픽셀에 있으므로 Math.ceil()로 반올림합니다. 요소에 아직 페인트 레코드가 없으면 captureElementImage()가 발생합니다. requestPaint()를 호출하고 다음 paint 이벤트에서 다시 시도하세요.
  • 재할당하면 텍스처가 지워지므로 첫 번째 업로드 시 또는 크기가 변경될 때만 수준 0을 할당합니다. 변경 가능한 저장소 사용: 요소의 크기가 변경되면 변경 불가능한 texStorage2D() 저장소의 크기를 조절할 수 없습니다.
// 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;
}

// 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 };
  1. texElementSubImage2D 메서드를 사용하여 canvas.onpaint 이벤트 핸들러 내에서 HTML 콘텐츠를 텍스처로 렌더링합니다. 다음과 같은 규칙을 따르세요.

    • 요소는 캔버스가 페인트한 후에만 업로드할 수 있습니다. paint 이벤트에서 업로드하고 canvas.requestPaint()를 호출하여 요청합니다.
    • 구성에서 { width, height }을 전달합니다. 캔버스의 크기 조정 방식과 관계없이 복사본이 할당된 텍스처를 채우도록 합니다.
    • 5단계와 같이 크기가 변경된 경우 텍스처를 재할당합니다.
// 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 (step 5).
  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);
  }
};

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) {
      // Update the texture with uploadElementWebGL()
    }
    // Render the scene, and sync the element geometry as shown in step 7
  };
  1. 요소 도형을 동기화합니다.

요소의 테두리 상자(CSS 픽셀 단위)를 캔버스(CSS 픽셀 단위)에 매핑하는 DOMMatrix를 계산합니다. 브라우저는 표시 영역 변환을 사용하여 3D 좌표 공간에서 CSS 좌표 공간으로 매핑해야 합니다. 이를 위해 다음을 수행합니다.

  • HTML 요소를 정규화합니다. HTML 요소의 크기는 픽셀 단위로 지정됩니다 (예: 너비 200px). 하지만 WebGL은 일반적으로 객체를 -0.5에서 0.5 사이의 '단위 정사각형'으로 취급합니다. 정규화하지 않으면 200px 버튼이 200배 더 크게 표시됩니다. 이 단계에서는 Y축도 뒤집습니다. CSS에서는 아래쪽이 양수이지만 WebGL에서는 위쪽이 양수이기 때문입니다.
  • WebGL MVP 행렬을 DOM 행렬로 변환합니다.
  • 캔버스 표시 영역에 매핑합니다. 이 단계는 '재조정' 단계입니다. 단위 공간 수학을 다시 확장하여 화면에 있는 <canvas> 요소의 CSS 픽셀 크기와 일치시키고 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),
  });

다음 옵션을 사용하여 Hit Test를 제어합니다.

  • updateElementGeometry()를 호출할 때마다 요소가 히트 테스트 순서의 맨 위로 이동합니다. { preserveHitTestOrder: true }을(를) 추월하여 순위를 유지하세요.
  • canvas.clearElementGeometry(element)를 호출하여 Hit Test에서 요소를 삭제합니다(예: 요소를 숨길 때).
  • canvasTransform의 좌표 공간에 있는 DOMRectInit인 { canvasClip }를 전달하여 히트 테스트 가능한 영역을 제한합니다(예: 요소의 일부만 그려진 경우).

예시 코드

<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() and allocateElementTexture() are defined in step 5.
  // uploadElementWebGL() is defined in step 6.
  // computeCanvasTransform() is defined in step 7.

  // 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>

권장사항

  • 필수: HTML-in-Canvas API를 사용하기 전에 브라우저 지원을 확인합니다.
  • 필수: 항상 content="drawable" 속성을 <canvas> 요소에 추가합니다.
  • 필수: 항상 drawable 속성을 캔버스의 직계 하위 요소에 추가합니다.
  • 필수: 상위 요소와 독립적으로 그릴 수 있는 모든 요소에 drawable 속성을 추가합니다.
  • 필수: onpaint 이벤트 핸들러를 사용하여 HTML 콘텐츠를 캔버스에 렌더링합니다.
  • 필수: texImage2D()를 사용하여 텍스처 버퍼를 한 번 사전 할당합니다 (초기화되거나 요소 크기가 변경될 때). 크기가 변경될 때만 텍스처를 재할당하고 TEXTURE_MIN_FILTER를 LINEAR로 설정합니다.
  • 필수: texElementSubImage2D 메서드를 사용하여 texImage2D()로 미리 할당한 텍스처에 HTML 콘텐츠를 렌더링합니다. 크기는 captureElementImage()입니다.
  • 필수: canvas.updateElementGeometry(element, { canvasTransform })를 호출하여 HTML 요소의 도형을 렌더링된 콘텐츠와 동기화합니다.
  • 필수: ResizeObserver를 사용하여 화면 크기를 관찰하고 기기 픽셀과 일치하도록 캔버스 크기를 업데이트합니다.
  • 교차 출처 콘텐츠는 지원되지 않으므로 캔버스에 삽입하지 마세요.
  • onpaint 이벤트 핸들러 내에서 ResizeObserver를 초기화하지 마세요. 메모리 누수가 발생할 수 있습니다.

대체 전략

HTML-in-Canvas API는 현재 모든 최신 브라우저에서 지원되지 않으므로 일반적으로 대체 전략이 필요합니다.

하지만 이 API의 향상된 성능 이점을 고려할 때 브라우저에서 지원하는 경우 HTML-in-Canvas를 사용해야 합니다.

대체 전략은 사용 사례에 따라 다릅니다. 예를 들어 캔버스의 대화형 HTML 콘텐츠의 경우 HTML-in-Canvas가 지원되지 않으면 CSS를 사용하여 HTML 콘텐츠를 캔버스 위에 배치합니다.

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-in-Canvas를 번역합니다.