הפעלת תוכן HTML אינטראקטיבי בסצנות תלת-ממדיות

תכונות אינטרנט: HTML ב-Canvas

‫HTML-in-Canvas API מאפשר להציג DOM אמיתי ישירות בתוך רכיב קנבס. כשמחילים את המאפיין content="drawable" על הקנבס ואת המאפיין drawable על רכיבי ה-HTML הצאצאים שלו בהקשרים של רינדור תלת-ממדי כמו WebGL,‏ WebGPU או Three.js, הרכיבים האלה מוצגים בצורה חלקה בסצנה התלת-ממדית. חשוב לציין שמכיוון שרכיבי ה-HTML נשארים חלק מעץ הפריסה הפעיל של ה-DOM, הם שומרים על אינטראקטיביות מלאה. כך המשתמשים יכולים ללחוץ על כפתורים, לבחור טקסט ולהפעיל מצבי התמקדות באופן מקורי, בלי צורך בהטלת קרניים (raycasting) מורכבת או בטיפול באירועים בהתאמה אישית.

איך מטמיעים

‫WebGL ו-WebGPU

כשמשתמשים ב-WebGL או ב-WebGPU, פועלים לפי השלבים הבאים:

  1. בודקים אם הדפדפן תומך ב-HTML-in-Canvas:

    if ('content' in HTMLCanvasElement.prototype) {
      // Use HTML in Canvas API
    } else {
      // Use fallback strategy
    }
    
  2. מאתחלים את <canvas> כדי לתמוך ברכיבי HTML צאצאים על ידי הוספת המאפיין content="drawable" לרכיב ה-HTML‏ <canvas>. ממקמים את תוכן ה-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. אפשר להעלות רכיב רק אחרי שהקנבס צייר אותו: צריך להפעיל את canvas.requestPaint() כדי לבקש אירוע paint.

משנים את גודל המרקם מ-canvas.captureElementImage(element). הערכים של width ו-height הם בפיקסלים של רשת ה-canvas (מאגר הגיבוי), אז צריך לעגל אותם כלפי מעלה באמצעות 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(), כמו שמוצג למעלה.
    • צריך להקצות את רמה 0 רק בהעלאה הראשונה או כשהגודל משתנה, כי הקצאה מחדש מנקה את הטקסטורה. שימוש באחסון שניתן לשינוי: אי אפשר לשנות את הגודל של אחסון 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 } בהגדרה. הוא גורם לעותק למלא את הטקסטורה שהוקצתה, בלי קשר לקנה המידה של ה-canvas.
    • אם הגודל השתנה, מקצים מחדש את הטקסטורה, כמו שמוצג למעלה.
// 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 כדי לדגום אותה ב-shaders. שימוש שגוי מדווח כשגיאת אימות של 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 מעובד ב-canvas. ‫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, ל-canvas, בפיקסלים של CSS. הדפדפן צריך למפות את מרחב הקואורדינטות התלת-ממדי למרחב הקואורדינטות של ה-CSS באמצעות טרנספורמציה של אזור התצוגה. כדי לעשות את זה:

  • מנרמלים את רכיב ה-HTML. הגודל של רכיבי HTML נמדד בפיקסלים (לדוגמה, רוחב של 200 פיקסלים). לעומת זאת, ב-WebGL בדרך כלל מתייחסים לאובייקטים כאל "ריבועי יחידה", למשל, בטווח שבין 0.5- ל-0.5. אם לא תבצעו נרמול, כפתור בגודל 200 פיקסלים ייראה גדול פי 200. בשלב הזה גם הופכים את ציר ה-Y, כי ב-CSS, הכיוון למטה הוא חיובי, אבל ב-WebGL, הכיוון למעלה הוא חיובי.
  • ממירים את מטריצת ה-MVP של WebGL למטריצת 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);
    }
    

אחר כך מעבירים את הטרנספורמציה אל canvas.updateElementGeometry() בתור canvasTransform. הפעולה הזו מעבירה את רכיב ה-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. רושמים את הרשת באמצעות InteractionManager כדי להפוך את רכיב ה-HTML לאינטראקטיבי. הוא שומר על הרכיב מיושר עם הרשת, ומסנכרן את הגיאומטריה של הרכיב עם אזור העריכה, כך שהקליקים ובחירת הטקסט פוגעים ברכיב ה-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

<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 לכל רכיב שמצויר, כולל רכיבים צאצאים ישירים של ה-canvas.
  • חובה: כשמשתמשים ב-WebGL או ב-WebGPU, צריך להשתמש בגורם מטפל באירועים מסוג onpaint כדי לעבד את תוכן ה-HTML ל-canvas.
  • חובה: כשמשתמשים ב-WebGL, צריך להקצות מראש את מאגר הטקסטורות באמצעות texImage2D() פעם אחת (כשהוא מאותחל או כשגודל הרכיב משתנה). מקצים מחדש את הטקסטורה רק כשהגודל משתנה, ומגדירים את TEXTURE_MIN_FILTER ל-LINEAR.
  • חובה: כשמשתמשים ב-WebGL, צריך להשתמש ב-method‏ texElementSubImage2D כדי לעבד את תוכן ה-HTML למרקם שאתם מקצים מראש באמצעות texImage2D(), בגודל מ-captureElementImage().
  • חובה: כשמשתמשים ב-WebGPU, צריך להשתמש בשיטה drawElementImageToTexture כדי לעבד את תוכן ה-HTML לאלמנט ה-canvas. צריך לקבוע את גודל הטקסטורה מ-captureElementImage(), וליצור אותה מחדש רק כשהגודל משתנה. יוצרים את הטקסטורה עם שימוש ב-COPY_DST וב-RENDER_ATTACHMENT, ומעבירים את גודל הטקסטורה בתור size.
  • חובה: כשמשתמשים ב-WebGL או ב-WebGPU, צריך לסנכרן את הגיאומטריה של רכיב ה-HTML עם התוכן שעבר רינדור באמצעות קריאה ל-canvas.updateElementGeometry(element, { canvasTransform }).
  • חובה: כשמשתמשים ב-Three.js, צריך לרשום את הרשת באמצעות InteractionManager, ולקרוא ל-interactions.update() בלולאת האנימציה, כדי שרכיב ה-HTML יקבל קלט.
  • חובה: צריך לבדוק את גודל המסך ולעדכן את גודל אזור העריכה כך שיתאים לפיקסלים של המכשיר, למשל באמצעות ResizeObserver.
  • אל תטמיעו תוכן ממקורות שונים ב-Canvas, כי אין תמיכה בכך.
  • אל תפעילו את ResizeObserver בתוך הגורם המטפל באירועים onpaint, כי זה עלול לגרום לדליפות זיכרון.

אסטרטגיות חלופיות

ה-API של HTML-in-Canvas לא נתמך כרגע בכל הדפדפנים המודרניים, ולכן בדרך כלל נדרשת אסטרטגיית גיבוי.

עם זאת, לאור היתרונות של ה-API הזה בשיפור הביצועים, כדאי להשתמש ב-HTML-in-Canvas אם הדפדפן תומך בו.

אסטרטגיית ברירת המחדל תלויה בתרחיש לדוגמה. לדוגמה, אם אתם רוצים להציג תוכן HTML אינטראקטיבי ב-canvas, אבל אין תמיכה ב-HTML-in-Canvas, אתם יכולים להציב את תוכן ה-HTML מעל ה-canvas באמצעות CSS.

HTML-in-Canvas polyfill

כדי לדמות את ה-API של HTML-in-Canvas בדפדפנים שלא תומכים בו, צריך להשתמש בסקריפט ה-polyfill הבא.

  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.