اعمال سایه‌زن‌های WebGL به محتوای HTML

ویژگی‌های وب: HTML در بوم

سایه‌زن‌های WebGL جلوه‌های دیداری قدرتمند با شتاب‌دهی GPU ارائه می‌دهند و قابلیت‌های پیشرفته‌ای مانند اعوجاج‌های موج‌دار پویا، مدل‌های نورپردازی، درجه‌بندی رنگ، و تبدیل‌های رأس سفارشی را ممکن می‌سازند. ‫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 به‌جای داده‌های پیکسل، فضای ذخیره‌سازی بافت را اختصاص دهید. این کار حافظه GPU را با اندازه و قالب داده‌شده بدون بارگذاری هیچ پیکسلی رزرو می‌کند. این قوانین را دنبال کنید:

  • اندازه بافت را از canvas.captureElementImage(element) تغییر دهید. ‫width و height آن در پیکسل‌های شبکه بوم (ذخیره‌سازی پشتیبان) هستند، بنابراین آن‌ها را با Math.ceil() گرد کنید. captureElementImage() اگر عنصر هنوز سابقه رنگ‌آمیزی نداشته باشد، خطا می‌دهد: با requestPaint() تماس بگیرید و در رویداد paint بعدی دوباره امتحان کنید.
  • سطح ۰ را فقط در اولین بارگذاری یا زمانی که اندازه تغییر می‌کند اختصاص دهید، زیرا تخصیص مجدد بافت را پاک می‌کند. استفاده از فضای ذخیره‌سازی تغییرپذیر: اندازه فضای ذخیره‌سازی تغییرناپذیر 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 را بااستفاده از روش 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() را در حلقه فراخوانی کنید تا مطمئن شوید محتوای 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 برحسب پیکسل است (برای مثال، عرض ۲۰۰ پیکسل). بااین‌حال، WebGL معمولاً اشیاء را به‌عنوان «مربع‌های واحد» درنظر می‌گیرد، برای مثال، از ۰٫۵- تا ۰٫۵. اگر نرمال‌سازی نکنید، دکمه ۲۰۰ پیکسلی شما ۲۰۰ برابر بزرگ‌تر به‌نظر خواهد رسید. این مرحله همچنین محور Y را برعکس می‌کند، زیرا در CSS، پایین مثبت است، اما در WebGL، بالا مثبت است.
  • تبدیل ماتریس WebGL MVP به ماتریس DOM.
  • به ناحیه نمایش بوم نگاشت کنید. این مرحله فاز «مقیاس‌بندی مجدد» است: این مرحله ریاضیات فضای واحد را دوباره گسترش می‌دهد تا با ابعاد پیکسل CSS عنصر <canvas> شما در صفحه مطابقت داشته باشد و محور Y را برمی‌گرداند.
  • محاسبه تبدیل نهایی. ماتریس‌ها را به‌ترتیب ضرب کنید: Viewport * MVP * Normalization. ترکیب آن‌ها در یک تبدیل نهایی، «نقشه‌ای» تولید می‌کند که به مرورگر می‌گوید دقیقاً آن عنصر 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);
    }
    

سپس تبدیل را به‌عنوان canvasTransform به canvas.updateElementGeometry() منتقل کنید. این کار عنصر 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() است استفاده کنید.
  • اجباری: با فراخوانی canvas.updateElementGeometry(element, { canvasTransform })، هندسه عنصر HTML را با محتوای رندرشده همگام‌سازی کنید.
  • اجباری: از ResizeObserver برای مشاهده اندازه صفحه و به‌روزرسانی اندازه بوم برای مطابقت با پیکسل‌های دستگاه استفاده کنید.
  • محتوای میان‌مبدئی را در بوم نقاشی جاسازی نکنید، زیرا پشتیبانی نمی‌شود.
  • DO NOT initialize ResizeObserver within the onpaint event handler, as it may lead to memory leaks.

راهبردهای جایگزین

HTML-in-Canvas API درحال‌حاضر در همه مرورگرهای مدرن پشتیبانی نمی‌شود، بنابراین معمولاً به یک استراتژی جایگزین نیاز است.

بااین‌حال، با توجه به مزایای بهبود عملکرد این API، اگر مرورگر از HTML-in-Canvas پشتیبانی می‌کند، باید از آن استفاده شود.

راهبرد جایگزین به مورد استفاده بستگی دارد. برای مثال، برای محتوای HTML تعاملی در بوم، اگر HTML-in-Canvas پشتیبانی نمی‌شود، محتوای HTML را بااستفاده از 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. برای ترجمه HTML-in-Canvas، روش installHtmlInCanvasPolyfill() را اجرا کنید.