3D sahnelerde etkileşimli HTML içeriğini etkinleştirme

Web özellikleri: Tuvalde HTML

HTML-in-Canvas API, gerçek DOM'un doğrudan bir canvas öğesi içinde oluşturulmasına olanak tanır. WebGL, WebGPU veya Three.js gibi 3D oluşturma bağlamlarına uygulandığında, tuvale content="drawable" özelliğini ve alt HTML öğelerine drawable özelliğini eklemek, bu öğelerin 3D sahneye sorunsuz bir şekilde yansıtılmasını sağlar. HTML öğeleri, etkin DOM düzen ağacının bir parçası olarak kaldığı için tam etkileşim sağlar. Bu sayede kullanıcılar, karmaşık ışın izleme veya özel etkinlik işleme gerek kalmadan düğmeleri tıklayabilir, metin seçebilir ve odaklanmış durumları yerel olarak tetikleyebilir.

Nasıl uygulanır?

WebGL ve WebGPU

WebGL veya WebGPU kullanırken aşağıdaki adımları uygulayın:

  1. Tarayıcıda HTML-in-Canvas'ın desteklenip desteklenmediğini kontrol edin:

    if ('content' in HTMLCanvasElement.prototype) {
      // Use HTML in Canvas API
    } else {
      // Use fallback strategy
    }
    
  2. <canvas> HTML öğesine content="drawable" özelliğini ekleyerek alt HTML öğelerini desteklemek için <canvas> öğesini ilk kullanıma hazırlayın. HTML içeriğinizi <canvas> öğesinin içine yerleştirin ve çizdiğiniz her öğeye drawable özelliğini ekleyin. drawable özelliğine sahip bir öğe, kendisi de drawable olan iç içe yerleştirilmiş alt öğeler hariç olmak üzere alt ağacını yakalar:

    <canvas id="canvas" content="drawable">
      <div id="html-content" drawable></div>
    </canvas>
    
  3. Bulanıklığı önlemek için tuval ızgaranızı cihazın ölçeklendirme faktörüne uyacak şekilde ölçeklendirin:

    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 içeriğini bir canvas.onpaint etkinlik işleyicide dokuya dönüştürün. Bir öğe yalnızca tuval tarafından boyandıktan sonra yüklenebilir: paint etkinliği istemek için canvas.requestPaint() çağrısı yapın.

Dokuyu canvas.captureElementImage(element) olarak boyutlandırın. width ve height değerleri, tuval ızgarası (yedekleme deposu) piksel cinsinden olduğundan Math.ceil() ile yukarı yuvarlayın. Öğenin henüz boyama kaydı yoksa captureElementImage() hata verir: requestPaint() yöntemini çağırın ve bir sonraki paint etkinliğinde tekrar deneyin.

// 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 bağlamında, önce texImage2D yöntemini kullanarak doku desteğini önceden tahsis edin. Bu kılavuzdaki snippet'ler WebGL 2 bağlamını (canvas.getContext("webgl2")) kullanır. Piksel verileri yerine null ile texImage2D() yöntemini çağırarak doku depolama alanı ayırın. Bu yöntem, piksel yüklemeden belirli boyut ve biçimdeki GPU belleğini ayırır. Aşağıdaki kurallara uyun:
    • Yukarıda gösterildiği gibi dokuyu captureElementImage() boyutuna getirin.
    • Yeniden tahsis etme işlemi dokuyu temizlediğinden, 0. seviyeyi yalnızca ilk yüklemede veya boyut değiştiğinde tahsis edin. Değişebilir depolama alanı kullanın: Değişmez texStorage2D() depolama alanı, öğenin boyutu değiştiğinde yeniden boyutlandırılamaz.
// 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 };
  • Ardından, WebGL bağlamında texElementSubImage2D yöntemini kullanarak HTML içeriğini dokuya dönüştürün. Aşağıdaki kurallara uyun:
    • Yapılandırmada { width, height } değerini iletin. Tuvalin nasıl ölçeklendirildiğine bakılmaksızın kopyanın ayrılan dokuyu doldurmasını sağlar.
    • Yukarıda gösterildiği gibi, boyut değiştiyse dokuyu yeniden tahsis edin.
// 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 bağlamında drawElementImageToTexture yöntemini kullanın. Aşağıdaki kurallara uyun:
    • Dokuyu COPY_DST ve RENDER_ATTACHMENT kullanımıyla oluşturun ve gölgelendiricilerde örneklemek için TEXTURE_BINDING ekleyin. Yanlış kullanım, istisna olarak değil, WebGPU doğrulama hatası olarak bildirilir.
    • Doku boyutunu size olarak iletin. size olmadan, daha büyük bir öğe dokuya kırpılır. size ile öğe, doldurulacak şekilde ölçeklendirilir.
    • WebGPU dokuları yeniden boyutlandırılamaz. Öğe boyutu değiştiğinde dokuyu yeniden oluşturun ve dokuya referans veren bağlama gruplarını yeniden oluşturun.
// 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);
  }
};

Sahneyi oluşturmak için requestAnimationFrame döngüsü kullanırken HTML içeriğinin tuvalde oluşturulmasını sağlamak için döngü içinde canvas.requestPaint() yöntemini çağırın. event.changedElements, oluşturma işlemi değişen öğeleri listeler. Yalnızca alt HTML öğelerinde bir güncelleme yapıldıysa dokuyu yeniden yüklediğinizden emin olun:

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. Öğe geometrisini senkronize edin.

Öğenin kenarlık kutusunu CSS pikseli cinsinden tuvale (CSS pikseli cinsinden) eşleyen bir DOMMatrix hesaplayın. Tarayıcının, görüntü alanı dönüşümü kullanarak 3D koordinat alanından CSS koordinat alanına eşleme yapması gerekir. Bunu kolaylaştırmak için aşağıdakileri yapın:

  • HTML öğesini normalleştirin. HTML öğeleri piksel cinsinden boyutlandırılır (ör. 200 piksel genişlik). Ancak WebGL, nesneleri genellikle "birim kareler" olarak ele alır (ör. -0,5 ile 0,5 arasında). Normalleştirme yapmazsanız 200 piksellik düğmeniz 200 kat daha büyük görünür. Bu adımda Y ekseni de ters çevrilir. CSS'de aşağı yön pozitifken WebGL'de yukarı yön pozitiftir.
  • WebGL MVP matrisini DOM matrisine dönüştürün.
  • Tuval görüntü alanıyla eşleştirin. Bu adım, "yeniden ölçeklendirme" aşamasıdır: Bu birim uzay matematiğini ekrandaki <canvas> öğenizin CSS piksel boyutlarıyla eşleşecek şekilde yeniden genişletir ve Y eksenini geri çevirir.
  • Nihai dönüşümü hesaplayın. Matrisleri şu sırayla çarpın: Görüntü alanı * MVP * Normalleştirme. Bunları tek bir nihai dönüşümde birleştirmek, tarayıcıya HTML öğesinin 3D çizimle hizalanması için tam olarak nerede bulunması gerektiğini söyleyen bir "harita" oluşturur. Tarayıcı, perspektif bölme işlemini gerçekleştirir.

    // 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);
    }
    

Ardından dönüşümü canvas.updateElementGeometry() öğesine canvasTransform olarak iletin. Bu işlem, HTML öğesini doğrudan oluşturulan piksellerinin üzerine taşır. Bu sayede, kullanıcı bir düğmeyi tıkladığında veya metin seçtiğinde gerçek HTML öğesine ulaşır. Tarayıcı, öğenin erişilebilirlik sınırları için de dönüşümü kullanır (ör. ekran okuyucu odak halkaları için):

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

İsabet testini kontrol etmek için aşağıdaki seçenekleri kullanın:

  • Her updateElementGeometry() çağrısı, öğeyi isabet testi sırasının en üstüne taşır. { preserveHitTestOrder: true } kartı, konumunu koruyacak.
  • Bir öğeyi isabet testinden kaldırmak için (ör. gizlediğinizde) canvas.clearElementGeometry(element) yöntemini çağırın.

Three.js

  1. Tarayıcıda HTML-in-Canvas'ın desteklenip desteklenmediğini kontrol edin:

    if ('content' in HTMLCanvasElement.prototype) {
      // Use HTML in Canvas API
    } else {
      // Use fallback strategy
    }
    
  2. HTML içeriği için özel bir geometri ve malzeme oluşturun.

  3. DOM öğesini THREE.HTMLTexture'a iletin. Oluşturucu, öğeyi tuvaline ekler ve gerekli özellikleri ayarlar:

      material.map = new THREE.HTMLTexture(element);
      mesh = new THREE.Mesh( geometry, material );
      scene.add( mesh );
    
  4. HTML öğesini etkileşimli hale getirmek için ağı InteractionManager ile kaydedin. Öğeyi ağ ile hizalı tutar ve öğe geometrisini tuvalle senkronize eder. Böylece tıklamalar ve metin seçimi gerçek HTML öğesine isabet eder. Oluşturmadan önce animasyon döngüsünde interactions.update() yöntemini çağırın:

  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 );

Örnek kod

WebGL Canvas

<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);
}

En İyi Uygulamalar

  • ZORUNLU: HTML-in-Canvas API'yi kullanmadan önce tarayıcı desteğini kontrol edin.
  • ZORUNLU: WebGL veya WebGPU kullanırken <canvas> öğesine her zaman content="drawable" özelliğini ekleyin.
  • ZORUNLU: WebGL veya WebGPU kullanırken, tuvalin doğrudan alt öğeleri de dahil olmak üzere çizdiğiniz her öğeye drawable özelliğini ekleyin.
  • ZORUNLU: WebGL veya WebGPU kullanırken HTML içeriğini tuvalde oluşturmak için bir onpaint etkinlik işleyici kullanın.
  • ZORUNLU: WebGL kullanırken doku arabelleğini texImage2D() ile bir kez (başlatıldığında veya öğe boyutu değiştiğinde) önceden tahsis edin. Dokuyu yalnızca boyut değiştiğinde yeniden tahsis edin ve TEXTURE_MIN_FILTER değerini LINEAR olarak ayarlayın.
  • ZORUNLU: WebGL kullanırken HTML içeriğini texImage2D() ile önceden tahsis ettiğiniz ve captureElementImage() boyutunda bir dokuya dönüştürmek için texElementSubImage2D yöntemini kullanın.
  • ZORUNLU: WebGPU kullanırken HTML içeriğini tuvalde oluşturmak için drawElementImageToTexture yöntemini kullanın. Dokuyu captureElementImage() boyutuna getirin ve yalnızca boyut değiştiğinde yeniden oluşturun. Dokuyu COPY_DST ve RENDER_ATTACHMENT kullanımıyla oluşturun ve doku boyutunu size olarak iletin.
  • ZORUNLU: WebGL veya WebGPU kullanırken canvas.updateElementGeometry(element, { canvasTransform }) yöntemini çağırarak HTML öğesinin geometrisini oluşturulan içerikle senkronize edin.
  • ZORUNLU: Three.js kullanırken mesh'i InteractionManager ile kaydedin ve animasyon döngüsünde interactions.update() işlevini çağırın. Böylece HTML öğesi giriş alır.
  • ZORUNLU: Ekran boyutunu gözlemleyin ve tuval boyutunu cihaz pikselleriyle eşleşecek şekilde güncelleyin (ör. ResizeObserver kullanarak).
  • Desteklenmediği için çapraz kaynaklı içerikleri tuvale YERLEŞTİRMEYİN.
  • Bellek sızıntılarına yol açabileceğinden ResizeObserver öğesini onpaint etkinlik işleyicisinde ilk kullanıma hazırlamayın.

Yedek stratejiler

HTML-in-Canvas API şu anda tüm modern tarayıcılarda desteklenmediğinden genellikle bir yedek strateji gerekir.

Ancak bu API'nin sağladığı performans avantajları göz önüne alındığında, tarayıcı destekliyorsa HTML-in-Canvas kullanılmalıdır.

Yedek strateji, kullanım alanına bağlıdır. Örneğin, tuvaldeki etkileşimli bir HTML içeriği için Tuvalde HTML desteklenmiyorsa HTML içeriğini CSS kullanarak tuvalin üstüne yerleştirin.

HTML-in-Canvas polyfill

HTML-in-Canvas API'yi desteklemeyen tarayıcılarda bu API'yi taklit etmek için aşağıdaki polyfill komut dosyasını kullanın.

  1. Kitaplığı yükleyin veya yerleştirin:

    # 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'ı çevirmek için installHtmlInCanvasPolyfill() yöntemini çalıştırın.