ใช้ WebGL Shader กับเนื้อหา HTML

ฟีเจอร์บนเว็บ: HTML ใน Canvas

เฉดสี WebGL มีเอฟเฟกต์ภาพที่เร่งด้วย GPU ที่มีประสิทธิภาพ ซึ่งช่วยให้ใช้ความสามารถขั้นสูงได้ เช่น การบิดเบือนระลอกคลื่นแบบไดนามิก โมเดลแสง การเกรดสี และการเปลี่ยนรูปแบบเวอร์เท็กซ์ที่กำหนดเอง HTML-in-Canvas API ช่วยให้นักพัฒนาแอปใช้พื้นผิว WebGL กับเนื้อหา HTML ได้ ซึ่งจะช่วยให้สามารถใช้ Fragment และ Vertex Shader ประสิทธิภาพสูงกับคอมโพเนนต์ UI แบบอินเทอร์แอกทีฟเต็มรูปแบบ เช่น ปุ่ม ช่องป้อนข้อมูล และ Rich Text ได้โดยตรง โดยยังคงรักษาการช่วยเหลือพิเศษ การเลือกข้อความ และการจัดการเหตุการณ์ 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. ปรับขนาดตารางกริดของ Canvas ให้ตรงกับค่าตัวคูณมาตราส่วนของอุปกรณ์เพื่อป้องกันไม่ให้ภาพเบลอ โดยทำดังนี้

    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 อยู่ในพิกเซลตารางกริดของ Canvas (ที่เก็บข้อมูลสำรอง) ดังนั้นให้ปัดเศษขึ้นด้วย 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 โดยทำตามกฎต่อไปนี้

    • คุณจะอัปโหลดองค์ประกอบได้หลังจากที่ Canvas แสดงผลแล้วเท่านั้น อัปโหลดจากกิจกรรม paint และโทรหา canvas.requestPaint() เพื่อขอรับใบรับรอง
    • ส่ง { width, height } ในการกำหนดค่า ซึ่งจะทำให้สำเนาเติมเต็มพื้นผิวที่จัดสรรไว้ ไม่ว่า Canvas จะปรับขนาดอย่างไรก็ตาม
    • จัดสรรพื้นผิวใหม่หากขนาดมีการเปลี่ยนแปลง ดังที่แสดงในขั้นตอนที่ 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 แสดงผลใน 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 with uploadElementWebGL()
    }
    // Render the scene, and sync the element geometry as shown in step 7
  };
  1. ซิงค์เรขาคณิตขององค์ประกอบ

คำนวณ DOMMatrix ที่แมปกล่องเส้นขอบขององค์ประกอบในหน่วยพิกเซล CSS กับ Canvas ในหน่วยพิกเซล CSS เบราว์เซอร์ต้องแมปจากพื้นที่พิกัด 3 มิติไปยังพื้นที่พิกัด CSS โดยใช้การเปลี่ยนรูปแบบวิวพอร์ต โดยทำดังนี้

  • ปรับองค์ประกอบ HTML ให้เป็นมาตรฐาน องค์ประกอบ HTML มีขนาดเป็นพิกเซล (เช่น กว้าง 200 พิกเซล) อย่างไรก็ตาม WebGL มักจะถือว่าออบเจ็กต์เป็น "สี่เหลี่ยมจัตุรัสหน่วย" เช่น ตั้งแต่ -0.5 ถึง 0.5 หากไม่ปรับให้เป็นมาตรฐาน ปุ่มขนาด 200 พิกเซลจะดูใหญ่ขึ้น 200 เท่า ขั้นตอนนี้ยังพลิกแกน Y ด้วย เนื่องจากใน CSS ค่าบวกจะอยู่ด้านล่าง แต่ใน WebGL ค่าบวกจะอยู่ด้านบน
  • แปลงเมทริกซ์ MVP ของ WebGL เป็นเมทริกซ์ DOM
  • แมปกับวิวพอร์ต Canvas ขั้นตอนนี้คือระยะ "การปรับขนาดใหม่" ซึ่งจะยืดคณิตศาสตร์พื้นที่หน่วยนั้นกลับออกไปเพื่อให้ตรงกับขนาดพิกเซล CSS ขององค์ประกอบ <canvas> บนหน้าจอ และพลิกแกน Y กลับ
  • คำนวณการเปลี่ยนรูปแบบสุดท้าย คูณเมทริกซ์ตามลำดับ: Viewport * MVP * Normalization การรวมการเปลี่ยนรูปแบบทั้ง 3 แบบเข้าด้วยกันเป็นการเปลี่ยนรูปแบบสุดท้ายจะสร้าง "แผนที่" ที่บอกเบราว์เซอร์อย่างชัดเจนว่าองค์ประกอบ HTML นั้นควรอยู่ตรงไหนเพื่อให้สอดคล้องกับภาพวาด 3 มิติ เบราว์เซอร์จะทำการแบ่งมุมมอง

    // 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 ลงในองค์ประกอบย่อยโดยตรงของ Canvas เสมอ
  • จำเป็น: เพิ่มแอตทริบิวต์ drawable ลงในทุกองค์ประกอบที่คุณต้องการให้วาดแยกจากองค์ประกอบหลักได้
  • จำเป็น: ใช้แฮนเดิลเหตุการณ์ onpaint เพื่อแสดงผลเนื้อหา HTML ไปยัง Canvas
  • จำเป็น: จัดสรรบัฟเฟอร์พื้นผิวล่วงหน้าด้วย texImage2D() หนึ่งครั้ง (เมื่อเริ่มต้นหรือเมื่อขนาดองค์ประกอบมีการเปลี่ยนแปลง) จัดสรรพื้นผิวใหม่เฉพาะเมื่อขนาดมีการเปลี่ยนแปลง และตั้งค่า TEXTURE_MIN_FILTER เป็น LINEAR
  • จำเป็น: ใช้เมธอด texElementSubImage2D เพื่อแสดงผลเนื้อหา HTML เป็นพื้นผิวที่คุณจัดสรรไว้ล่วงหน้าด้วย texImage2D() ซึ่งมีขนาดตั้งแต่ captureElementImage()
  • จำเป็น: ซิงค์เรขาคณิตขององค์ประกอบ HTML กับเนื้อหาที่แสดงผลโดยเรียกใช้ canvas.updateElementGeometry(element, { canvasTransform })
  • บังคับ: ใช้ ResizeObserver เพื่อสังเกตขนาดหน้าจอและอัปเดตขนาด Canvas ให้ตรงกับพิกเซลของอุปกรณ์
  • อย่าฝังเนื้อหาแบบข้ามต้นทางใน Canvas เนื่องจากระบบไม่รองรับ
  • อย่าเริ่มต้น ResizeObserver ภายในเครื่องจัดการเหตุการณ์ onpaint เนื่องจากอาจทำให้หน่วยความจำรั่วไหล

กลยุทธ์สำรอง

ปัจจุบันเบราว์เซอร์ที่ทันสมัยบางรุ่นยังไม่รองรับ HTML-in-Canvas API ดังนั้นจึงมักต้องใช้กลยุทธ์สำรอง

อย่างไรก็ตาม เนื่องจาก API นี้มีประโยชน์ด้านประสิทธิภาพที่ดีขึ้น จึงควรใช้ HTML-in-Canvas หากเบราว์เซอร์รองรับ

กลยุทธ์สำรองจะขึ้นอยู่กับกรณีการใช้งาน ตัวอย่างเช่น สำหรับเนื้อหา HTML แบบอินเทอร์แอกทีฟใน Canvas หากระบบไม่รองรับ HTML-in-Canvas ให้วางเนื้อหา HTML ไว้ด้านบนของ Canvas โดยใช้ CSS

Polyfill ของ HTML-in-Canvas

ใช้สคริปต์ Polyfill ต่อไปนี้เพื่อเลียนแบบ 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. เรียกใช้เมธอด installHtmlInCanvasPolyfill() เพื่อแปล HTML-in-Canvas