فعال کردن محتوای HTML تعاملی در صحنه‌های سه‌بعدی

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

‫HTML-in-Canvas API امکان ارائه DOM واقعی را مستقیماً در عنصر بوم فراهم می‌کند. وقتی در زمینه‌های پردازشی سه‌بعدی مانند WebGL،‏ WebGPU، یا Three.js اعمال می‌شود، افزودن مشخصه content="drawable" به بوم و مشخصه drawable به عناصر HTML فرزند آن، امکان می‌دهد این عناصر به‌طور یکپارچه در صحنه سه‌بعدی نمایش داده شوند. نکته مهم این است که چون عناصر HTML همچنان بخشی از درخت چیدمان DOM فعال هستند، تعامل‌پذیری کامل خود را حفظ می‌کنند و به کاربران اجازه می‌دهند تا بدون نیاز به پرتوتابی پیچیده یا مدیریت رویداد سفارشی، به‌طور بومی روی دکمه‌ها کلیک کنند، نوشتار را انتخاب کنند، و حالت‌های تمرکز را فعال کنند.

نحوه پیاده‌سازی

WebGL و WebGPU

هنگام استفاده از WebGL یا WebGPU، این مراحل را دنبال کنید:

  1. بررسی کنید که آیا HTML-in-Canvas در مرورگر پشتیبانی می‌شود یا نه:

    if ('content' in HTMLCanvasElement.prototype) {
      // Use HTML in Canvas API
    } else {
      // Use fallback strategy
    }
    
  2. <canvas> را با افزودن مشخصه content="drawable" به عنصر HTML <canvas> مقداردهی اولیه کنید تا از عناصر HTML فرزند پشتیبانی کند. محتوای HTML خود را داخل عنصر <canvas> قرار دهید و ویژگی drawable را به هر عنصری که ترسیم می‌کنید اضافه کنید. عنصری با drawable زیردرخت خود را ضبط می‌کند، به‌جز نوادگان تودرتو که آن‌ها هم drawable هستند:

    <canvas id="canvas" content="drawable">
      <div id="html-content" drawable></div>
    </canvas>
    
  3. برای جلوگیری از تار شدن، شبکه بوم نقاشی‌تان را با ضریب مقیاس دستگاه مطابقت دهید:

    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);
    
  4. محتوای HTML را در یک گرداننده رویداد canvas.onpaint به بافت تبدیل کنید. عنصر فقط پس‌از اینکه بوم آن را رنگ‌آمیزی کرد می‌تواند بارگذاری شود: برای درخواست رویداد paint، canvas.requestPaint() را فراخوانی کنید.

اندازه بافت را از 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 به‌جای داده‌های پیکسل اختصاص دهید، که حافظه GPU را با اندازه و قالب داده‌شده بدون بارگذاری هیچ پیکسلی رزرو می‌کند. این قوانین را دنبال کنید:
    • بافت را از captureElementImage() اندازه کنید، همان‌طور که در بالا نشان داده شده است.
    • سطح ۰ را فقط در اولین بارگذاری یا زمانی که اندازه تغییر می‌کند اختصاص دهید، زیرا تخصیص مجدد بافت را پاک می‌کند. استفاده از فضای ذخیره‌سازی تغییرپذیر: اندازه فضای ذخیره‌سازی تغییرناپذیر 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) {
    // Update the texture as shown above
  }
  // Render the scene, and sync the element geometry as shown in step 5
};
  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) را فراخوانی کنید.

Three.js

  1. بررسی کنید که آیا HTML-in-Canvas در مرورگر پشتیبانی می‌شود یا نه:

    if ('content' in HTMLCanvasElement.prototype) {
      // Use HTML in Canvas API
    } else {
      // Use fallback strategy
    }
    
  2. هندسه و ماده سفارشی برای محتوای HTML ایجاد کنید.

  3. عنصر DOM را به THREE.HTMLTexture منتقل کنید. پردازنده عنصر را به بوم خود اضافه می‌کند و مشخصه‌های الزامی را تنظیم می‌کند:

      material.map = new THREE.HTMLTexture(element);
      mesh = new THREE.Mesh( geometry, material );
      scene.add( mesh );
    
  4. برای تعاملی کردن عنصر HTML، مش را با InteractionManager ثبت کنید. این ویژگی عنصر را با مش هم‌تراز نگه می‌دارد و هندسه عنصر را با بوم همگام‌سازی می‌کند تا کلیک‌ها و انتخاب نوشتار به عنصر HTML واقعی برخورد کند. interactions.update() را در حلقه پویانمایی، قبل‌از رندر کردن فراخوانی کنید:

  import { InteractionManager } from 'three/addons/interaction/InteractionManager.js';

  const interactions = new InteractionManager();
  interactions.connect( renderer, camera );
  interactions.add( mesh );

  // In the animation loop
  interactions.update();
  renderer.render( scene, camera );

کد نمونه

بوم 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 4.
  // computeCanvasTransform() is defined in step 5.

  // 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 4.
  // computeCanvasTransform() is defined in step 5.

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

Three.js

// 1. Initialize Three.js camera, scene, renderer, mesh, interactions;

// 2. Ensure HTML-in-Canvas feature support
if (!('content' in HTMLCanvasElement.prototype)) {
  // Use a fallback strategy
}

// 3. Initialize the source HTML DOM element
const element = document.createElement('div');
element.innerHTML = '<h1>Hello World</h1>';

// 4. Create geometry and material
const geometry = new RoundedBoxGeometry( 100, 100, 100, 10, 10 );
const material = new THREE.MeshStandardMaterial( { roughness: 0, metalness: 0.5 } );

// 5. Pass the DOM element into THREE.HTMLTexture
material.map = new THREE.HTMLTexture(element);

mesh = new THREE.Mesh( geometry, material );
scene.add( mesh );

// 6. Make the HTML element interactive
interactions = new InteractionManager();
interactions.connect( renderer, camera );
interactions.add( mesh );

// 7. Render Loop
function animate() {
  // Keep the HTML element aligned with the mesh, so it receives input
  interactions.update();
  renderer.render(scene, camera);
}

روال‌های مطلوب

  • الزامی: قبل‌از استفاده از HTML-in-Canvas API، پشتیبانی مرورگر از آن را بررسی کنید.
  • اجباری: هنگام استفاده از WebGL یا WebGPU، همیشه مشخصه content="drawable" را به عنصر <canvas> اضافه کنید.
  • اجباری: هنگام استفاده از WebGL یا WebGPU، مشخصه drawable را به هر عنصری که ترسیم می‌کنید، ازجمله فرزندان مستقیم بوم، اضافه کنید.
  • اجباری: هنگام استفاده از WebGL یا WebGPU، از یک گرداننده رویداد onpaint برای رندر کردن محتوای HTML در بوم استفاده کنید.
  • اجباری: هنگام استفاده از WebGL، میان‌گیر بافت را یک‌بار با texImage2D() پیش‌تخصیص دهید (وقتی مقداردهی اولیه می‌شود یا وقتی اندازه عنصر تغییر می‌کند). بافت را فقط زمانی که اندازه تغییر می‌کند بازتخصیص دهید و TEXTURE_MIN_FILTER را روی LINEAR تنظیم کنید.
  • اجباری: هنگام استفاده از WebGL، از روش texElementSubImage2D برای رندر کردن محتوای HTML به بافتی که ازقبل با texImage2D() اختصاص داده‌اید، با اندازه captureElementImage()، استفاده کنید.
  • اجباری: هنگام استفاده از WebGPU، از روش drawElementImageToTexture برای رندر کردن محتوای HTML در بوم استفاده کنید. اندازه بافت را از captureElementImage() تعیین کنید و فقط زمانی‌که اندازه تغییر می‌کند آن را بازسازی کنید. بافت را بااستفاده از COPY_DST و RENDER_ATTACHMENT ایجاد کنید و اندازه بافت را به‌صورت size ارسال کنید.
  • اجباری: هنگام استفاده از WebGL یا WebGPU، هندسه عنصر HTML را با محتوای رندرشده با فراخوانی canvas.updateElementGeometry(element, { canvasTransform }) همگام‌سازی کنید.
  • اجباری: هنگام استفاده از Three.js، مش را با InteractionManager ثبت کنید و interactions.update() را در حلقه پویانمایی فراخوانی کنید تا عنصر 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() را اجرا کنید.