Three.js使用InstancedMesh實現效能優化

2023-07-28 12:00:37

1. 引言

有這麼一種場景:需要渲染一座橋,橋有很多橋柱,橋柱除了位置與傾斜角度不完全相同外,其他均相同,由於橋柱數量很大,使用three.js繪製較為卡頓,如何優化?注意,要求後續能選中某個橋柱

2. 概念

2.1 合併幾何體

three.js官方教學裡提到,大量物件的優化 - three.js manual (threejs.org),使用合併幾何體

為什麼合併幾何體能優化繪製大量物件時的效能呢?

這得引出一個概念:繪製呼叫(draw call)

繪製呼叫(draw call)是指渲染引擎向GPU傳送繪製命令的過程,每個繪製呼叫都會告訴GPU繪製一個或多個三維物體或幾何體

在圖形渲染中,繪製呼叫的數量對效能有很大影響,較少的繪製呼叫通常意味著更高的效能,因為GPU在處理繪製呼叫時需要切換上下文和狀態,這會導致一定的開銷

在three.js中,由於繪製一個幾何體需要呼叫一次draw call,繪製很多幾何體就很消耗效能,所以合併多個幾何體為一個幾何體能減少draw call,從而實現繪製效能優化

合併幾何體會有一個突出的問題:無法單獨選擇其中某個幾何體

由於多個幾何體合併為一個幾何體,所以已經無法選擇原來的某個幾何體,即無法拾取單個幾何體

考慮到後續需要能選中橋柱,這個方案捨棄

2.2 InstancedMesh

three.js官方API檔案是這樣解釋:

範例化網格(InstancedMesh),一種具有範例化渲染支援的特殊版本的Mesh。你可以使用 InstancedMesh 來渲染大量具有相同幾何體與材質、但具有不同世界變換的物體。 使用 InstancedMesh 將幫助你減少 draw call 的數量,從而提升你應用程式的整體渲染效能

橋柱除了位置與傾斜角度不完全相同外,其他均相同,符合InstancedMesh的要求,同時InstancedMesh是可以選擇單個物體的,可以參考這個官方範例:three.js examples (threejs.org)

關於InstancedMesh,更為詳細的解釋可參考官方檔案:InstancedMesh – three.js docs (threejs.org)

綜上,筆者選用InstancedMesh來進行橋柱渲染優化,本文記述在three.js中使用InstancedMesh來實現繪製大量幾何體的效能優化

3. 初始情況

初始情況下使用多個幾何體來載入橋柱,其實就是多個圓柱體,數量為10980

範例程式碼如下:

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
  <style>
    html,
    body,
    canvas {
      height: 100%;
      width: 100%;
      margin: 0;
    }
  </style>

</head>

<body>
  <canvas id="canvas"></canvas>

  <script type="importmap">
		{
			"imports": {
				"three": "https://unpkg.com/three/build/three.module.js",
				"three/addons/": "https://unpkg.com/three/examples/jsm/"
			}
		}
	</script>

  <script type="module">
    import * as THREE from 'three';
    import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
    import Stats from 'three/addons/libs/stats.module.js'

    const scene = new THREE.Scene();

    const raycaster = new THREE.Raycaster();
    const mouse = new THREE.Vector2(1, 1);
    let mesh;
    const color = new THREE.Color();
    const white = new THREE.Color().setHex(0xffffff);

    // 建立效能監視器
    let stats = new Stats();
    // 將監視器新增到頁面中
    document.body.appendChild(stats.domElement)

    const canvas = document.querySelector('#canvas');
    const camera = new THREE.PerspectiveCamera(75, canvas.clientWidth / canvas.clientHeight, 0.1, 100000);
    camera.position.z = 5;
    camera.position.y = 60;
    camera.position.x = -1500;

    const renderer = new THREE.WebGLRenderer({
      canvas: document.querySelector('#canvas'),
      antialias: true
    });
    renderer.setSize(window.innerWidth, window.innerHeight, false)

    const controls = new OrbitControls(camera, renderer.domElement);

    function animate() {
      // 更新幀數
      stats.update()

      if (scene.children.length > 0) {
        raycaster.setFromCamera(mouse, camera);
        const intersections = raycaster.intersectObject(scene, true);
        if (intersections.length > 0) {
          // 獲取第一個相交的物體
          const intersectedObject = intersections[0].object;

          // 更新物體的顏色
          intersectedObject.material.color.set(0xff0000); // 設定為紅色
        }
      }

      requestAnimationFrame(animate);
      renderer.render(scene, camera);
    }
    animate();

    let count = 0
    let matrixList = []
    fetch("./資料.json").then(res => res.json()).then(res => {
      const name = Object.keys(res)
      for (let index = 0; index < 60; index++) {

        name.filter(item => item.includes("直立樁基")).forEach(item => {
          res[item].forEach(element => {
            const geometry = new THREE.CylinderGeometry(element.diameter / 2000, element.diameter / 2000, (element.height - element.depth) / 1000, 32);
            const material = new THREE.MeshBasicMaterial({ color: 0xffffff });
            const cylinder = new THREE.Mesh(geometry, material);

            const originalHeight = cylinder.geometry.parameters.height;
            cylinder.geometry.translate(0, -originalHeight / 2, 0);

            cylinder.position.set(element.x / 1000 * Math.random(), (element.z + element.height) / 1000, element.y / 1000)
            scene.add(cylinder);
            count++
          });
        })
      }
      console.log(count)
    })

    function onMouseMove(event) {
      event.preventDefault();
      mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
      mouse.y = - (event.clientY / window.innerHeight) * 2 + 1;
    }
    document.addEventListener('mousemove', onMouseMove);
  </script>
</body>

</html>

結果如下:

在筆者的電腦上只有20FPS,拾取功能(選擇單個柱子)正常

4. InstanceMesh優化

InstanceMesh在概念上可以理解為這是一組幾何體,只需根據instance id即可在這一組InstanceMesh上找到這個幾何體,所以InstanceMesh的使用方法主要就是根據InstanceMesh和instance id來確定選擇的是那個幾何體,從而進行位置變換、設定顏色等

更為詳細的InstanceMesh使用方法可參考官方檔案和範例:

筆者將上述程式碼修改為使用InstanceMesh的程式碼,主體程式碼如下:

import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import Stats from 'three/addons/libs/stats.module.js'

const scene = new THREE.Scene();

const raycaster = new THREE.Raycaster();
const mouse = new THREE.Vector2(1, 1);
let mesh;
const color = new THREE.Color();
const white = new THREE.Color().setHex(0xffffff);

// 建立效能監視器
let stats = new Stats();
// 將監視器新增到頁面中
document.body.appendChild(stats.domElement)

const canvas = document.querySelector('#canvas');
const camera = new THREE.PerspectiveCamera(75, canvas.clientWidth / canvas.clientHeight, 0.1, 100000);
camera.position.z = 5;
camera.position.y = 60;
camera.position.x = -1500;

const renderer = new THREE.WebGLRenderer({
    canvas: document.querySelector('#canvas'),
    antialias: true
});
renderer.setSize(window.innerWidth, window.innerHeight, false)

const controls = new OrbitControls(camera, renderer.domElement);

function animate() {
    // 更新幀數
    stats.update()

    if (mesh) {
        raycaster.setFromCamera(mouse, camera);

        const intersection = raycaster.intersectObject(mesh);

        if (intersection.length > 0) {
            const instanceId = intersection[0].instanceId;
            console.log(instanceId)
            mesh.setColorAt(instanceId, new THREE.Color(0xff0000));
            mesh.instanceColor.needsUpdate = true;
        }
    }

    requestAnimationFrame(animate);
    renderer.render(scene, camera);
}
animate();

let count = 0
let matrixList = []
fetch("./資料.json").then(res => res.json()).then(res => {
    const name = Object.keys(res)
    for (let index = 0; index < 60; index++) {

        name.filter(item => item.includes("直立樁基")).forEach(item => {
            res[item].forEach(element => {
                count++
                matrixList.push(new THREE.Matrix4().makeTranslation(element.x / 1000 * Math.random(), (element.z + element.height) / 1000, element.y / 1000))
            });
        })
    }
    console.log(count)

    const element = {
        diameter: 1200,
        depth: 72000
    }
    const geometry = new THREE.CylinderGeometry(element.diameter / 2000, element.diameter / 2000, element.depth / 1000, 32);
    const material = new THREE.MeshBasicMaterial({ color: 0xffffff });

    mesh = new THREE.InstancedMesh(geometry, material, count);

    for (let i = 0; i < count; i++) {
        mesh.setColorAt(i, color);
        mesh.setMatrixAt(i, matrixList[i]);
    }
    scene.add(mesh);
})

function onMouseMove(event) {
    event.preventDefault();
    mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
    mouse.y = - (event.clientY / window.innerHeight) * 2 + 1;
}
document.addEventListener('mousemove', onMouseMove);

在筆者的電腦上有60FPS,拾取功能(選擇單個柱子)正常

5. 參考

[1] 大量物件的優化 - three.js manual (threejs.org)

[2] three.js 效能優化的幾種方法 - 可愛的黑精靈 - 部落格園 (cnblogs.com)

[3] InstancedMesh – three.js docs (threejs.org)

[4] three.js/examples/webgl_instancing_raycast.html at master · mrdoob/three.js (github.com)

[5] three.js examples (threejs.org)