एचटीएमएल कॉन्टेंट पर WebGL शेडर लागू करना

वेब सुविधाएं: कैनवस में एचटीएमएल

WebGL शेडर, जीपीयू की मदद से विज़ुअल इफ़ेक्ट को बेहतर बनाते हैं. इससे डाइनैमिक रिपल डिस्टॉर्शन, लाइटिंग मॉडल, कलर ग्रेडिंग, और कस्टम वर्टेक्स ट्रांसफ़ॉर्मेशन जैसी बेहतर सुविधाएं मिलती हैं. HTML-in-Canvas API की मदद से, डेवलपर एचटीएमएल कॉन्टेंट पर WebGL टेक्सचर लागू कर सकते हैं. इससे, पूरी तरह से इंटरैक्टिव यूज़र इंटरफ़ेस (यूआई) कॉम्पोनेंट पर, हाई-परफ़ॉर्मेंस फ़्रैगमेंट और वर्टेक्स शेडर को नेटिव तौर पर लागू किया जा सकता है. जैसे, बटन, इनपुट फ़ील्ड, और रिच टेक्स्ट. साथ ही, नेटिव सुलभता, टेक्स्ट चुनने, और डीओएम इवेंट को मैनेज करने की सुविधा को बनाए रखा जा सकता है.

इन्हें कैसे लागू करें

  1. देखें कि ब्राउज़र में HTML-in-Canvas की सुविधा काम करती है या नहीं:

    if ('content' in HTMLCanvasElement.prototype) {
      // Use HTML in Canvas API
    } else {
      // Use fallback strategy
    }
    
  2. <canvas> एचटीएमएल एलिमेंट में content="drawable" एट्रिब्यूट जोड़ें.

  3. अपने एचटीएमएल कॉन्टेंट को <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. texImage2D तरीके का इस्तेमाल करके, पहले से ही टेक्सचर बैकिंग को ऐलोकेट करें. इस गाइड में दिए गए स्निपेट, WebGL 2 कॉन्टेक्स्ट (canvas.getContext("webgl2")) का इस्तेमाल करते हैं.

सबसे पहले, पिक्सल डेटा के बजाय 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 इवेंट हैंडलर में मौजूद टेक्सचर में एचटीएमएल कॉन्टेंट को रेंडर करें. ऐसा करते समय इन नियमों का पालन करें:

    • कैनवस पर पेंट किए जाने के बाद ही, किसी एलिमेंट को अपलोड किया जा सकता है. paint इवेंट से अपलोड करें और अनुरोध करने के लिए canvas.requestPaint() को कॉल करें.
    • कॉन्फ़िगरेशन में { 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 (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() को कॉल करें. इससे यह पक्का किया जा सकेगा कि एचटीएमएल कॉन्टेंट, कैनवस पर रेंडर हो. event.changedElements उन एलिमेंट की सूची दिखाता है जिनकी रेंडरिंग में बदलाव हुआ है. पक्का करें कि टेक्सचर को सिर्फ़ तब फिर से अपलोड किया जाए, जब डिसेंडेंट एचटीएमएल एलिमेंट में कोई अपडेट किया गया हो:

  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. एलिमेंट की ज्यामिति को सिंक करें.

DOMMatrix की गिनती करें, जो एलिमेंट के बॉर्डर बॉक्स को सीएसएस पिक्सल में, कैनवस पर सीएसएस पिक्सल में मैप करता है. ब्राउज़र को व्यूपोर्ट ट्रांसफ़ॉर्म का इस्तेमाल करके, 3D कोऑर्डिनेट स्पेस से सीएसएस कोऑर्डिनेट स्पेस में मैप करना होगा. इसके लिए, यह तरीका अपनाएं:

  • एचटीएमएल एलिमेंट को सामान्य बनाएं. एचटीएमएल एलिमेंट का साइज़, पिक्सल में तय किया जाता है. उदाहरण के लिए, 200 पिक्सल चौड़ा. हालांकि, WebGL आम तौर पर ऑब्जेक्ट को "यूनिट स्क्वेयर" के तौर पर ट्रीट करता है. उदाहरण के लिए, -0.5 से 0.5 तक. अगर आपने नॉर्मलाइज़ नहीं किया, तो आपका 200 पिक्सल का बटन 200 गुना बड़ा दिखेगा. यह चरण Y-ऐक्सिस को भी फ़्लिप करता है. ऐसा इसलिए, क्योंकि सीएसएस में, डाउन पॉज़िटिव होता है, लेकिन WebGL में, अप पॉज़िटिव होता है.
  • WebGL एमवीपी मैट्रिक्स को DOM मैट्रिक्स में बदलें.
  • कैनवस व्यूपोर्ट पर मैप करें. यह चरण "री-स्केलिंग" फ़ेज़ है: यह उस यूनिट-स्पेस मैथ को वापस स्ट्रेच करता है, ताकि स्क्रीन पर आपके <canvas> एलिमेंट के सीएसएस पिक्सल डाइमेंशन से मैच किया जा सके. साथ ही, Y-ऐक्सिस को वापस फ़्लिप करता है.
  • फ़ाइनल ट्रांसफ़ॉर्म का हिसाब लगाएं. मैट्रिक्स को इस क्रम में गुणा करें: व्यूपोर्ट * एमवीपी * नॉर्मलाइज़ेशन. इन दोनों को एक फ़ाइनल ट्रांसफ़ॉर्म में जोड़ने से एक "मैप" बनता है. इससे ब्राउज़र को पता चलता है कि एचटीएमएल एलिमेंट को कहां रखना है, ताकि वह 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 के तौर पर पास करें. इससे एचटीएमएल एलिमेंट, रेंडर किए गए पिक्सल के ठीक ऊपर आ जाता है. इससे यह पक्का होता है कि जब कोई उपयोगकर्ता किसी बटन पर क्लिक करता है या टेक्स्ट चुनता है, तो वह असल एचटीएमएल एलिमेंट पर ही क्लिक करता है. ब्राउज़र, एलिमेंट की सुलभता सीमाओं के लिए भी ट्रांसफ़ॉर्म का इस्तेमाल करता है. उदाहरण के लिए, स्क्रीन रीडर की फ़ोकस रिंग के लिए:

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

हिट टेस्टिंग को कंट्रोल करने के लिए, इन विकल्पों का इस्तेमाल करें:

  • updateElementGeometry() को कॉल करने पर, एलिमेंट हिट-टेस्टिंग ऑर्डर में सबसे ऊपर चला जाता है. { preserveHitTestOrder: true } को पास करें, ताकि वह अपनी पोज़िशन बनाए रखे.
  • हिट टेस्टिंग से किसी एलिमेंट को हटाने के लिए, canvas.clearElementGeometry(element) को कॉल करें. उदाहरण के लिए, जब उसे छिपाया जाता है.
  • हिट-टेस्ट किए जा सकने वाले हिस्से को सीमित करने के लिए, { canvasClip } को पास करें. यह canvasTransform के कोऑर्डिनेट स्पेस में मौजूद DOMRectInit है. उदाहरण के लिए, जब एलिमेंट का सिर्फ़ कुछ हिस्सा ड्रॉ किया गया हो.

कोड का उदाहरण

<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 का इस्तेमाल करने से पहले, यह देख लें कि आपका ब्राउज़र इसके साथ काम करता है या नहीं.
  • ज़रूरी: <canvas> एलिमेंट में हमेशा content="drawable" एट्रिब्यूट जोड़ें.
  • ज़रूरी: कैनवस के डायरेक्ट चाइल्ड एलिमेंट में, हमेशा drawable एट्रिब्यूट जोड़ें.
  • ज़रूरी: हर उस एलिमेंट में drawable एट्रिब्यूट जोड़ें जिसे आपको उसके पैरंट एलिमेंट से अलग दिखाना है.
  • ज़रूरी: कैनवस पर एचटीएमएल कॉन्टेंट रेंडर करने के लिए, onpaint इवेंट हैंडलर का इस्तेमाल करें.
  • ज़रूरी: texImage2D() का इस्तेमाल करके, टेक्सचर बफ़र को पहले से तय करें. ऐसा सिर्फ़ एक बार करें (जब इसे शुरू किया जाए या एलिमेंट का साइज़ बदला जाए). टेक्सचर को सिर्फ़ तब फिर से असाइन करें, जब साइज़ में बदलाव हो. साथ ही, TEXTURE_MIN_FILTER को LINEAR पर सेट करें.
  • ज़रूरी: एचटीएमएल कॉन्टेंट को टेक्सचर में रेंडर करने के लिए, texElementSubImage2D तरीके का इस्तेमाल करें. इसके लिए, आपको texImage2D() का इस्तेमाल करके, पहले से ही टेक्सचर तय करना होगा. इसका साइज़ captureElementImage() होना चाहिए.
  • ज़रूरी: canvas.updateElementGeometry(element, { canvasTransform }) को कॉल करके, एचटीएमएल एलिमेंट की ज्यामिति को रेंडर किए गए कॉन्टेंट के साथ सिंक करें.
  • ज़रूरी: स्क्रीन का साइज़ देखने के लिए, ResizeObserver का इस्तेमाल करें. साथ ही, कैनवस के साइज़ को डिवाइस के पिक्सल के हिसाब से अपडेट करें.
  • कैनवस में क्रॉस-ऑरिजिन कॉन्टेंट को एंबेड न करें, क्योंकि यह काम नहीं करता.
  • ResizeObserver को onpaint इवेंट हैंडलर में शुरू न करें, क्योंकि इससे मेमोरी लीक हो सकती है.

फ़ॉलबैक रणनीतियां

फ़िलहाल, HTML-in-Canvas API सभी मॉडर्न ब्राउज़र के साथ काम नहीं करता. इसलिए, आम तौर पर फ़ॉलबैक रणनीति की ज़रूरत होती है.

हालांकि, इस एपीआई की बेहतर परफ़ॉर्मेंस को देखते हुए, अगर ब्राउज़र पर HTML-in-Canvas काम करता है, तो इसका इस्तेमाल किया जाना चाहिए.

फ़ॉलबैक की रणनीति, इस्तेमाल के उदाहरण पर निर्भर करती है. उदाहरण के लिए, कैनवस में इंटरैक्टिव एचटीएमएल कॉन्टेंट के लिए, अगर HTML-in-Canvas काम नहीं करता है, तो सीएसएस का इस्तेमाल करके एचटीएमएल कॉन्टेंट को कैनवस के सबसे ऊपर रखें.

HTML-in-Canvas polyfill

HTML-in-Canvas API के साथ काम न करने वाले ब्राउज़र में, इस एपीआई की नकल करने के लिए, यहां दी गई polyfill स्क्रिप्ट का इस्तेमाल करें.

  1. लाइब्रेरी को इंस्टॉल या एंबेड करें:

    # Install
    npm install three-html-render
    
    # Embed
    <script src="https://cdn.jsdelivr.net/npm/three-html-render/dist/polyfill.js"></script>
    
  2. HTML-in-Canvas का अनुवाद करने के लिए, installHtmlInCanvasPolyfill() तरीका इस्तेमाल करें.