图形学基础——鼠标拾取优化「概念篇」

1,264 阅读5分钟

前言

最近的工作有一些关于web3d方面的知识,在使用鼠标拾取时遇到了一些性能问题。于是进行了一下浅入的研究

web3d对于web端的技术方案一般是采用webgl进行实现。但纯写webgl又十分的繁琐,故可以采用threejs进行一些简化

同时react庞大的生态社区也提供了关于threejs的渲染器react-three-fiber。简单起见,我们的例子代码以react-three-fiber和threejs为主。「本篇也不会有多少code」

什么是鼠标拾取?

鼠标拾取,其实就是3维场景下鼠标对于几何物体的选中交互,不同于传统二维页面的交互。因为维度的上升。我们无法拿到完整坐标信息,仅能拿到一个相对于屏幕的二维坐标。怎么办呢?

图形学中关于鼠标拾取有两种常见方案

  1. 射线法拾取
  2. gpu拾取

其中射线拾取是最常见、最简单的拾取方案,所谓的射线法就是以鼠标点为原点,向屏幕深处放射一条射线。通过计算获取到与当前射线相交的几何体,并以此获取几何体实例,进行逻辑交互

下面先来看一个简单的案例,

射线法拾取

由于react-three-fiber封装的过于完善,我们先写一个threejs的例子

threejs的使用非常简单「当然前提是要有那么一点概念知识,去官网看一眼便可清楚。这里太基础的东西就不做描述了」

首先简单建一个3维场景

import { useEffect, useRef } from 'react'
import { AxesHelper, BoxGeometry, Mesh, MeshBasicMaterial, PerspectiveCamera, Scene, Vector3, WebGLRenderer } from 'three'
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'

const Example01 = () => {
  const divRef = useRef<HTMLDivElement>(null)
  useEffect(() => {
    const scene = new Scene()
    const camera = new PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000)
    camera.position.z = -10

    const renderer = new WebGLRenderer()
    renderer.setSize(window.innerWidth, window.innerHeight)
    divRef.current!.appendChild(renderer.domElement)

    new OrbitControls(camera, renderer.domElement)

    const geo = new BoxGeometry(1, 1, 1)
    const material = new MeshBasicMaterial()

    const mesh = new Mesh(geo, material)
    mesh.position.set(1, 1, 1)
    scene.add(mesh)

    const axesHelper = new AxesHelper(5)
    scene.add(axesHelper)

    function animate() {
      requestAnimationFrame(animate)
      renderer.render(scene, camera)
    }

    animate()

    renderer.render(scene, camera)
  })

  return (
        <div ref={divRef}></div>
  )
}

export default Example01

来一个基于鼠标位置的光线投影

import { useEffect, useRef } from 'react'
+ import { AxesHelper, BoxGeometry, Mesh, MeshBasicMaterial, PerspectiveCamera, Raycaster, Scene, Vector2, WebGLRenderer } from 'three'
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'

const Example01 = () => {
+ const pointer = useRef(new Vector2())
  const divRef = useRef<HTMLDivElement>(null)
+ const { current: raycaster } = useRef(new Raycaster())

+ const hanldePointerEvent = (event: any) => {
+    pointer.current.x = (event.clientX / window.innerWidth) * 2 - 1
+    pointer.current.y = -(event.clientY / window.innerHeight) * 2 + 1
+ }


  useEffect(() => {
    const scene = new Scene()
    const camera = new PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000)
    camera.position.z = -10

    const renderer = new WebGLRenderer()
    renderer.setSize(window.innerWidth, window.innerHeight)
    divRef.current!.appendChild(renderer.domElement)

    new OrbitControls(camera, renderer.domElement)

    const geo = new BoxGeometry(1, 1, 1)
    const material = new MeshBasicMaterial()

    const mesh = new Mesh(geo, material)
    mesh.position.set(1, 1, 1)
    scene.add(mesh)

    const axesHelper = new AxesHelper(5)
    scene.add(axesHelper)

    function animate() {
+ 		raycaster.setFromCamera(pointer.current, camera)
+      const intersects = raycaster.intersectObjects(scene.children)

+     for (let i = 0; i < intersects.length; i++)
+         intersects[0].object.type === 'Mesh' && intersects[0].object.material.color.set(0xFF0000)


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

    animate()

    renderer.render(scene, camera)
  })

  return (
-       <div ref={divRef}></div>
+       <div ref={divRef} onClick={hanldePointerEvent}></div>
  )
}

export default Example01

查看效果

接下来是基于react-three-fiber的代码示例

刚才简单的在react系统中的例子你可能觉得怪怪的,下面我们使用react-three-fiber来还原一下上面的操作

import { useState } from 'react'
import { Canvas } from '@react-three/fiber'
import { OrbitControls } from '@react-three/drei'

import './example.css'

const Example01 = () => {
  const [curColor, setCurColor] = useState('#fff')

  return (
       <div className='canvas'>
         <Canvas>
            <mesh onPointerUp={() => {
              setCurColor('red')
            }}>
                <boxGeometry args={[1, 1, 1]} />
                <meshBasicMaterial color={curColor} />
            </mesh>
            <OrbitControls/>
            <axesHelper args={[5]}/>
        </Canvas>
       </div>
  )
}

export default Example01

一样的效果

射线法有什么问题

问题复现

让我们接下来重新写一个案例,我们往场景了放入100个圆环缓冲扭结几何体再来看一下效果

import { Canvas } from '@react-three/fiber'
import { OrbitControls } from '@react-three/drei'

import './example.css'

const Example01 = () => {
  const addTorusKnotGeometry = () => {
    return Array(100).fill(null).map(
      (_, i) => (
        <mesh onPointerMove={() => {}} key={i} rotation={[Math.random() * 10, Math.random() * 10, 0]} position={[Math.random(), Math.random(), Math.random()]}>
          <torusKnotGeometry args={[1, 0.4, 400, 100]} />
          <meshBasicMaterial color={0xFFFF00} />
        </mesh>
      ),
    )
  }
  return (
    <div className='canvas'>
      <Canvas>
        {addTorusKnotGeometry()}
        <OrbitControls />
        <axesHelper args={[5]} />
      </Canvas>
    </div>
  )
}

export default Example01

注意看左上角的帧率,此时我的电脑还是16g内存m1芯片

为什么掉帧这么严重

我们上面说了,射线法的原理是以鼠标点为原点,向屏幕深处放射一条射线。通过计算获取到与当前射线相交的物体,注意这句「计算获取到与当前射线相交的物体」。我怎么知道哪些物体与当前射线相交呢?

正常理解方式,当前场景中的所有几何体依次跟当前射线进行一次计算不就知道哪些几何体和当前射线存在相交的情况了么,难道这里webgl也是这样做的么

我们来实验一下,改写一个mesh原型上的raycast方法。

  import { Canvas } from '@react-three/fiber'
+ import { Mesh } from 'three'
  import { OrbitControls } from '@react-three/drei'

  import './example.css'

+	Mesh.prototype.raycast = () => {
+	console.log(111)
+	}

	const Example01 = () => {
	const addTorusKnotGeometry = () => {
	return Array(100).fill(null).map(
	(_, i) => (
	<mesh onPointerMove={() => {}} key={i} rotation={[Math.random() * 10, Math.random() * 10, 0]} position={[Math.random(), Math.random(), Math.random()]}>
	<torusKnotGeometry args={[1, 0.4, 400, 100]} />
	<meshBasicMaterial color={0xFFFF00} />
	</mesh>
	),
	)
	}
	return (
	<div className='canvas'>
	<Canvas>
	{addTorusKnotGeometry()}
	<OrbitControls />
	<axesHelper args={[5]} />
	</Canvas>
	</div>
	)
	}

export default Example01

看一下效果

我去真的是这样,一点优化没有帮我们做。每一次的鼠标一动都需要计算此时的100个物体的射线相交情况...这怎么能不卡顿呢,怎么进行优化呢?

射线法优化方案

图形学中比较常用的有两种优化方式

  1. bvh (Bounding volume hierarchy)
  2. 八叉树

这里我们主要解释一些bvh,这里每一点可以研究的深度都很深。我这里主要浅显的以我目前的理解解释一遍

什么是bvh呢,先看一下来自谷歌的解释

bvh是一组空间几何对象上的树结构。构成树叶节点的所有几何对象都包裹在包围体中。然后将这些对象分组为小集,并包含在较大的边界体积内。反过来,这些也以递归方式分组并包含在其他更大的包围体中,最终导致在数的顶部具有单个包围体的树结构。包围体层次结构用于有效的支持对几何对象集的多种操作,例如碰撞检测和光线追踪

上面几个关键词语我已经进行了高亮。

  1. 一个空间树形数据结构
  2. 对象分组为较小的集合

简单理解,就是针对当前空间的物体进行分组所形成的一个树形数据结构。3维可能不好理解,接下我以2维的例子解释

假设,当前屏幕的几何体排列如上。首先我们先来第一次分组

第二次

第三次

此时便可得到一个二叉树

目前虽然每一个叶子节点仍然都是包围体,但是其当前包围体只有一个几何体。图上我就不再继续划分了

此时如果我想拾取物体5,那么就只需要遍历3个节点了。是不是性能就有了质的提升了呢?

但是怎么构建这颗bvh树呢?每次的划分机制又需要怎么设计呢,无脑二分?我们后面慢慢研究解释