تطبيق تظليلات WebGL على محتوى HTML

ميزات الويب: HTML في لوحة الرسم

توفّر برامج تظليل WebGL تأثيرات مرئية فعّالة ومسرَّعة بواسطة وحدة معالجة الرسومات، ما يتيح إمكانات متقدّمة مثل تشويهات التموّج الديناميكية ونماذج الإضاءة وتدرّج الألوان وتحويلات الرؤوس المخصّصة. تتيح واجهة HTML-in-Canvas API للمطوّرين تطبيق زخارف WebGL على محتوى HTML. يتيح ذلك تطبيق برامج تظليل الأجزاء والرؤوس العالية الأداء بشكلٍ أصلي على مكوّنات واجهة المستخدم التفاعلية بالكامل، مثل الأزرار وحقول الإدخال والنصوص المنسّقة، مع الاحتفاظ بميزات تسهيل الاستخدام الأصلية وتحديد النص ومعالجة أحداث DOM.

كيفية الإعداد

  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. يجب تخصيص خلفية النسيج مسبقًا باستخدام طريقة texImage2D. تستخدم المقتطفات في هذا الدليل سياق WebGL 2 (canvas.getContext("webgl2")).

خصِّص مساحة تخزين الزخرفة أولاً من خلال استدعاء texImage2D() باستخدام null بدلاً من بيانات وحدات البكسل، ما يؤدي إلى حجز ذاكرة وحدة معالجة الرسومات بالحجم والتنسيق المحدّدَين بدون تحميل أي وحدات بكسل. اتّبِع القواعد التالية:

  • يمكنك تحديد حجم الزخرفة من 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. اعرض محتوى HTML على النسيج داخل معالج أحداث canvas.onpaint باستخدام طريقة texElementSubImage2D. اتّبِع القواعد التالية:

    • لا يمكن تحميل عنصر إلا بعد أن ترسمه لوحة الرسم. يمكنك تحميلها من حدث "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. مزامنة هندسة العناصر

احتسِب DOMMatrix يربط مربّع حدود العنصر، بوحدات بكسل CSS، بلوحة العرض، بوحدات بكسل CSS. يحتاج المتصفّح إلى الربط بين مساحة الإحداثيات الثلاثية الأبعاد ومساحة إحداثيات CSS باستخدام تحويل إطار العرض. لتسهيل ذلك، اتّبِع الخطوات التالية:

  • تطبيع عنصر HTML. يتم تحديد حجم عناصر HTML بالبكسل (على سبيل المثال، عرض 200 بكسل). ومع ذلك، يتعامل WebGL عادةً مع العناصر على أنّها "مربّعات وحدات"، على سبيل المثال، تتراوح من -0.5 إلى 0.5. إذا لم تقم بالتسوية، فسيظهر الزر الذي يبلغ حجمه 200 بكسل أكبر بمقدار 200 مرة. تعمل هذه الخطوة أيضًا على قلب المحور Y، لأنّه في CSS، يكون الاتجاه لأسفل موجبًا، ولكن في WebGL، يكون الاتجاه لأعلى موجبًا.
  • تحويل مصفوفة WebGL MVP إلى مصفوفة DOM
  • الربط بإطار عرض اللوحة هذه الخطوة هي مرحلة "إعادة تغيير الحجم": فهي تمدد حسابات مساحة الوحدة مرة أخرى لتتطابق مع أبعاد بكسل CSS لعنصر <canvas> على الشاشة، وتقلب المحور Y مرة أخرى.
  • احسب التحويل النهائي. اضرب المصفوفات بالترتيب: إطار العرض * MVP * التسوية. وعند دمجها في تحويل نهائي واحد، يتم إنشاء "خريطة" تخبر المتصفّح بالمكان الذي يجب أن يظهر فيه عنصر HTML بالضبط ليتوافق مع الرسم الثلاثي الأبعاد. ينفّذ المتصفّح عملية القسمة المنظورية.

    // 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) لإزالة عنصر من اختبار مؤشر الماوس، على سبيل المثال، عند إخفائه.
  • يجب تمرير { canvasClip }، وهو DOMRectInit في مساحة الإحداثيات في canvasTransform، للحدّ من المنطقة القابلة للاختبار، على سبيل المثال، عند رسم جزء فقط من العنصر.

مثال على الرمز

<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 لعرض محتوى HTML على مادة يتم تخصيصها مسبقًا باستخدام texImage2D()، بحجم من captureElementImage().
  • إجراء إلزامي: عليك مزامنة شكل عنصر HTML مع المحتوى المعروض من خلال استدعاء canvas.updateElementGeometry(element, { canvasTransform }).
  • إجراء إلزامي: استخدِم ResizeObserver لمراقبة حجم الشاشة وتعديل حجم اللوحة ليتطابق مع وحدات بكسل الجهاز.
  • لا تضمِّن محتوى متعدّد المصادر في لوحة رسم، لأنّ ذلك غير متاح.
  • لا تبدأ ResizeObserver ضمن معالِج الأحداث onpaint، لأنّ ذلك قد يؤدي إلى تسرُّب الذاكرة.

استراتيجيات احتياطية

لا تتوفّر واجهة HTML-in-Canvas API حاليًا في جميع المتصفّحات الحديثة، لذا عادةً ما تكون هناك حاجة إلى استراتيجية احتياطية.

ومع ذلك، نظرًا لمزايا الأداء المحسّنة لواجهة برمجة التطبيقات هذه، يجب استخدام HTML-in-Canvas إذا كان المتصفّح يتيح ذلك.

تعتمد استراتيجية الاحتياط على حالة الاستخدام. على سبيل المثال، بالنسبة إلى محتوى HTML التفاعلي في لوحة الرسم، إذا لم يكن HTML-in-Canvas متوافقًا، يمكنك وضع محتوى HTML في أعلى لوحة الرسم باستخدام CSS.

‫HTML-in-Canvas polyfill

استخدِم نص polyfill البرمجي التالي لمحاكاة واجهة برمجة التطبيقات HTML-in-Canvas في المتصفحات التي لا تتيح استخدامها.

  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.