BVH 层次包围结构

313 阅读6分钟

源码

github.com/buglas/canv…

学习目标

  • 使用BVH优化选择

知识点

  • BVH

1-BVH 原理

BVH(Bounding Volume Hierarchy)是一种层次包围结构,适用于对数量较多的几何对象进行碰撞检测或光线追踪。

在BVH中,所有的几何物体都会被包在包围体里面,包围体外面还会包着一个更大的包围体,以此递归地包裹下去,最终形成的根节点会包裹着整个场景。

image-20240515112945617

这种结构对于物体较多的场景中非常有效。

解释一下BVH区域划分的原理:

1.计算包裹所有物体的最大的包围盒。

2.寻找包围盒中长宽中最大的那一项,将其拆成2个子包围盒。

3.把属于子包围盒的物体存入其中。

4.若子包围盒中的物体数量大于区域划分的最小数量,递归执行步骤1。

2-BVH 对象

BVH对象负责计算图形集群的层次包围结构,其整体代码如下:

  • /src/lmm/physics/BVH.ts
import { BoundingBox } from "../geometry/Geometry"
import { Geometry } from "../geometry/Geometry"
import { Vector2 } from "../math/Vector2"
import { Graph2D } from "../objects/Graph2D"
import { StandStyle } from "../style/StandStyle"

/* 包围盒包含的所有目标对象,object类型后续可做扩展 */
export type BVHTargetType={
  object:Graph2D<Geometry,StandStyle>
  boundingBox:BoundingBox
}

class BVH {
  // 目标对象集合
    targets: BVHTargetType[]
  // 当图形小于minNum时,停止区域划分
  minNum:number
  // 包围盒
  boundingBox:BoundingBox={
    min:new Vector2(Infinity),
    max:new Vector2(-Infinity)
  }
    // 子节点
    children: BVH[] = []
    // 父节点
    parent?: BVH = undefined

  constructor(targets: BVHTargetType[]=[],minNum=4){
    this.targets=targets
    this.minNum=minNum
    // 计算targets的包围盒
    this.computeBoundingBox()
    // 计算层次结构
    this.computeHierarchy()
  }

  /* 包围盒尺寸 */
    get size() {
        const { boundingBox:{min, max} } = this
        return new Vector2(max.x - min.x, max.y - min.y)
    }

  /* 计算包裹targets的包围盒 */
  computeBoundingBox(){
    const {targets,boundingBox:b1}=this
    targets.forEach(({boundingBox:b2}) => {
      b1.min.expandMin(b2.min)
          b1.max.expandMax(b2.max)
    });
  }

  /* 计算targets的层次结构 */
  computeHierarchy(){
    // 清空包围盒
    this.children = []
    // 区域划分
    this.divide()
  }

  /* 分割包围盒 */
    divide() {
    const {targets,minNum, size, boundingBox:{min} } = this

    // 若target数量小于最小分割数量,则返回
    if(targets.length<=minNum){
      return this
    }

        // 切割方向
        const dir = size.x > size.y ? 'x' : 'y'
        // 切割位置
        const pos = min[dir] + size[dir] / 2
    
    // 将分targets 成两半
    const targetsA:BVHTargetType[]=[]
    const targetsB:BVHTargetType[]=[]
        targets.forEach(target => {
      const {boundingBox}=target
      const  { min, max }=boundingBox
      if (min[dir] + (max[dir] - min[dir]) / 2 < pos) {
                targetsA.push(target)
            } else {
                targetsB.push(target)
            }
        })

    // 若分割出空集,则返回
    if(!targetsA.length||!targetsB.length){
      return this
    }

    // 根据分割出的targets创建新的BVH,并将其作为当前BVH的子集
    for(let subTargets of [targetsA,targetsB]){
      const box: BVH = new BVH(subTargets,minNum)
      this.children.push(box)
    }

    return this;
    }

    /* 深度遍历包围盒
  callback1: 遍历所有包围盒
  callback2:用于中断遍历的条件
  */
    traverse(callback1: (obj: BVH) => void,callback2?: (obj: BVH) => boolean) {
    if(callback2&&!callback2(this)){return}
        callback1(this)
        const { children } = this
        for (let child of children) {
            if (child.children.length) {
                child.traverse(callback1,callback2)
            } else {
                callback1(child)
            }
        }
    }
}

export {BVH}

简单解释一下其封装思路。

BVH 需要一个用于区域划分的图形集合,这里的图形就是Graph2D对象。

与此同时,我们还得附带此对象的包围盒,用于区域划分。

所以,我们要传输给BVH 的图形数据就是这样的:

export type BVHTargetType={
  object:Graph2D<Geometry,StandStyle>
  boundingBox:BoundingBox
}

Graph2D图形的包围盒会随坐标系而变,所以我不在BVH中计算其包围盒。

BVH对象只专注于计算层次包围结构,其具体的计算流程我在代码里都有详细注释,所以不再赘述。

接下来,我们把BVH 显示出来看看。

3-BVHHelper 对象

BVHHelper 是专门用于显示BVH 的辅助对象。

  • /src/lmm/helper/BVHHelper.ts
import { RectGeometry } from '../geometry/RectGeometry';
import { Group } from '../objects/Group';
import { Graph2D } from '../objects/Graph2D';
import { BVH } from '../physics/BVH';
import { StandStyle } from '../style/StandStyle';


/* 样式的回调函数 */
type StyleCallback=(bvh:BVH,ind:number)=>StandStyle
/* 样式类型 */
type StyleType=StandStyle|StyleCallback

/* 默认样式 */
const defaultStyle=()=>(new StandStyle({strokeStyle:'#000'}))

class BVHHelper extends Group{
  bvh:BVH
  style: StyleType
  constructor(bvh:BVH,style: StyleType=defaultStyle()){
    super();
    this.bvh=bvh
    this.style=style
    this.update();
  }
  
  /* 遍历BVH的层级结构,建立相应的包围盒图形 */
  update(){
    const {bvh,style}=this
    this.clear()
    let ind=0
    bvh.traverse((target) => {
      const { boundingBox:{min, max} } = target
      const rectGeometry=new RectGeometry(min.x,min.y,max.x-min.x,max.y-min.y).close()
      const rectStyle=typeof style === 'object'?style:style(target,ind)
      const rectObj = new Graph2D(rectGeometry,rectStyle)
      this.add(rectObj)
      ind++;
    })
  }
}

export {BVHHelper}

其原理就是把划分出来的区域用矩形画出来。

4-BVH的显示示例

我提前准好了一堆用于测试BVH的图形数据。

  • /src/examples/dataLib/RectStore.ts
// 矩形集合
const RectStore = [
  {
      "x": 250,
      "y": 71,
      "w": 20,
      "h": 30,
      "r": 255,
      "g": 0,
      "b": 107,
      "counter": false
  },
  {
      "x": -19,
      "y": -250,
      "w": 30,
      "h": 60,
      "r": 196,
      "g": 0,
      "b": 255,
      "counter": false
  },
  ……
]

export {RectStore}

接下来建立一个vue页面,用于显示BVH。

  • /src/examples/BVH01.vue
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { Scene } from "../lmm/core/Scene";
import { RectGeometry } from "../lmm/geometry/RectGeometry";
import { Graph2D } from "../lmm/objects/Graph2D";
import { StandStyle } from "../lmm/style/StandStyle";
import { BVH, BVHTargetType } from "../lmm/physics/BVH";
import { BVHHelper } from "../lmm/helper/BVHHelper";
import { RectStore } from "./dataLib/RectStore";


// 获取父级属性
defineProps({
  size: { type: Object, default: { width: 0, height: 0 } },
});

// 对应canvas 画布的Ref对象
const canvasRef = ref<HTMLCanvasElement>();

/* 场景 */
const scene = new Scene();


/* 创建图形 */
const targets:BVHTargetType[]=[]
for(let { x, y, w, h, r, g, b, counter} of RectStore){
  // 创建矩形
  const rectGeometry = new RectGeometry(0,0,w, h, counter)
  const rectStyle = new StandStyle({
    fillStyle:`rgba(${r},${g},${b},0.5)`,
  })
  const rectObj = new Graph2D(rectGeometry, rectStyle)
  rectObj.position.set(x - w / 2, y - h / 2)
  rectObj.rotate=Math.random()*3.14
  scene.add(rectObj);
  
  // BVH的targets
  targets.push({
    object:rectObj,
    boundingBox:rectObj.getWorldBoundingBox()
  })
}

/* 实例化BVH包围盒 */
const rootBox = new BVH(targets, 4);


/* 显示包围盒 */
const boxStyle = new StandStyle({
  strokeStyle:'rgba(0,0,0,0.3)',
  lineWidth:2
});
const boxHelper = new BVHHelper(rootBox, boxStyle);
scene.add(boxHelper);

onMounted(() => {
  const canvas = canvasRef.value;
  if (canvas) {
    scene.setOption({ canvas });
    scene.render();
  }
});
</script>

<template>
  <canvas ref="canvasRef" :width="size.width" :height="size.height"></canvas>
</template>

<style scoped></style>

效果如下:

image-20240515112945617

解释一下其过程。

1.基于之前的RectStore.ts 示例化一堆矩形,并对其进行随机旋转。

for(let { x, y, w, h, r, g, b, counter} of RectStore){
  // 创建矩形
  const rectGeometry = new RectGeometry(0,0,w, h, counter)
  const rectStyle = new StandStyle({
    fillStyle:`rgba(${r},${g},${b},0.5)`,
  })
  const rectObj = new Graph2D(rectGeometry, rectStyle)
  rectObj.position.set(x - w / 2, y - h / 2)
  rectObj.rotate=Math.random()*3.14
  scene.add(rectObj);
  
  ……
}

2.计算每个矩阵在世界坐标系内的包围盒,然后将其存入targets。

const targets:BVHTargetType[]=[]
for(let { x, y, w, h, r, g, b, counter} of RectStore){
  // 创建矩形
  const rectGeometry = new RectGeometry(0,0,w, h, counter)
  const rectStyle = new StandStyle({
    fillStyle:`rgba(${r},${g},${b},0.5)`,
  })
  const rectObj = new Graph2D(rectGeometry, rectStyle)
  rectObj.position.set(x - w / 2, y - h / 2)
  rectObj.rotate=Math.random()*3.14
  scene.add(rectObj);
  
  // BVH的targets
  targets.push({
    object:rectObj,
    boundingBox:rectObj.getWorldBoundingBox()
  })
}

rectObj.getWorldBoundingBox() 方法可以获取图形在世界坐标系内的包围盒,其源码如下:

getWorldBoundingBox(){
  const {geometry,worldMatrix}=this
  return geometry.getBoundingBoxByMatrix(worldMatrix)
}

geometry.getBoundingBoxByMatrix(matrix) 方法可以获取几何体在某个坐标系内的包围盒。

3.基于targets,实例化BVH包围盒。

const rootBox = new BVH(targets, 4);

4.显示包围盒。

const boxStyle = new StandStyle({
  strokeStyle:'rgba(0,0,0,0.3)',
  lineWidth:2
});
const boxHelper = new BVHHelper(rootBox, boxStyle);
scene.add(boxHelper);

5.对于BVH 包围盒的颜色,我也可以根据图形在BVH 中的索引值设置成不同的颜色。

我们可以根据BVH 节点的索引位置让其显示不同的颜色。

// const bvhStyle = new StandStyle({
//   strokeStyle:'rgba(0,0,0,0.3)',
//   lineWidth:2
// });

// 颜色类,颜色转换
const color = new Color();
// 样式函数
const bvhStyle=function(bvh: BVH, ind: number){
  color.setHSL(ind/10, 1, 0.4);
  const strokeStyle = color.getStyle();
  const fillStyle = color.getRGBA(0.02);
  return new StandStyle({ strokeStyle, fillStyle });
}

现在,我们已经掌握了BVH 的代码实现。接下来我们可以将其应用到第二个面试题里。

5-射线与BVH的求交

我们可以再之前的PhysicUtils.ts 文件里,定义一个射线与BVH求交的方法。

  • /src/lmm/physics/PhysicUtils.ts
function intersectBVH(ray: Ray2, rootBox: BVH) {
    // 相交数据
    const arr: IntersectData[] = []
  // 遍历BVH层级
    rootBox.traverse(
        (box) => {
            // 若box没有子级,与此box中的图形做求交运算
      if(!box.children.length){ 
        const objects=box.targets.map(target=>target.object)
        const objs=intersectObjects(ray,objects,true,false)
        objs&&arr.push(...objs)
      }
        },
        (box) => intersectBoundingBox(ray, box.boundingBox)
    )
  if (arr.length) {
        // 按照相交距离排序
        arr.sort((a, b) => a.distance - b.distance)
        return arr
    }
    return null
}

rootBox.traverse(fun1,fun2) 方法会从外向内遍历BVH 划分出的区域,其中的两个参数都是回调函数。

fun2 会判断射线是否与相应区域的包围盒相交,若不相交,就不再向内遍历包围盒。

fun1 是在fun2返回true的前提下执行的,若相应区域的包围盒是最内部的包围盒,便判断射线是否与此包围盒中的图形相交。

6-使用BVH优化选择示例

我们接着第二个面试题,创建多个障碍物,演示一下如何用BVH优化选择。

建立一个新的vue页面。

  • /src/examples/BVH02.vue
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { Scene } from "../lmm/core/Scene";
import { Vector2 } from "../lmm/math/Vector2";
import { PolyExtendGeometry } from "../lmm/geometry/PolyExtendGeometry";
import { RectGeometry } from "../lmm/geometry/RectGeometry";
import { Graph2D } from "../lmm/objects/Graph2D";
import { StandStyle, StandStyleType } from "../lmm/style/StandStyle";
import { BVH, BVHTargetType } from "../lmm/physics/BVH";
import { BVHHelper } from "../lmm/helper/BVHHelper";
import { Color } from "../lmm/math/Color";
import { Ray2 } from "../lmm/math/Ray2";
import { PolyGeometry } from "../lmm/geometry/PolyGeometry";
import {
  IntersectData,
  intersectBVH,
  intersectObject,
} from "../lmm/physics/PhysicUtils";
import { CircleGeometry } from "../lmm/geometry/CircleGeometry";
import { RectStore } from "./dataLib/RectStore";

// 获取父级属性
defineProps({
  size: { type: Object, default: { width: 0, height: 0 } },
});

// 对应canvas 画布的Ref对象
const canvasRef = ref<HTMLCanvasElement>();

/* 场景 */
const scene = new Scene();

// 半径
const radius = 10;
// 起点
// const origin = new Vector2(-180, -220)
const origin = new Vector2(0, -0);
// 球体
const circleGeometry = new CircleGeometry(radius, 12);
const circleStyle = new StandStyle({
  fillStyle: "#00acec",
});
const circleObj = new Graph2D(circleGeometry, circleStyle);
circleObj.position.copy(origin);
circleObj.index = 3;
scene.add(circleObj);


/* 障碍物 */
const targets:BVHTargetType[]=[]
for(let { x, y, w, h, r, g, b, counter} of RectStore){
  // 创建矩形
  const rectGeometry = new RectGeometry(0,0,w, h, counter)
  const rectStyle = new StandStyle({
    fillStyle:`rgba(${r},${g},${b},0.5)`,
  })
  const rectObj = new Graph2D(rectGeometry, rectStyle)
  rectObj.position.set(x - w / 2, y - h / 2);
  scene.add(rectObj);

  // 扩展矩形
  const worldRectGeometry=rectGeometry
    .clone()
    .applyMatrix3(rectObj.worldMatrix)
    .computeSegmentNormal()
    .close()
  const polyExtendGeometry = new PolyExtendGeometry(worldRectGeometry, radius)
    .computeBoundingBox()
    .close();
  const polyExtendStyle = new StandStyle({
    strokeStyle:`rgba(${r},${g},${b},1)`,
    lineDash: [3],
  });
  const polyExtend = new Graph2D(polyExtendGeometry, polyExtendStyle);
  polyExtend.name = "obstacle";
  scene.add(polyExtend);
  polyExtendGeometry.computeBoundingBox();

  
  // BVH的targets
  targets.push({
    object:polyExtend,
    boundingBox:polyExtendGeometry.boundingBox
  })
}

/* 为障碍物创建BVH包围盒 */
const rootBox = new BVH(targets, 4);

/* 显示包围盒 */
const color = new Color();
const boxHelper = new BVHHelper(rootBox, (box: BVH, ind: number)=>{
  color.setHSL(Math.min(0.5, ind * 0.1), 1, 0.4);
  const strokeStyle = color.getStyle();
  const fillStyle = color.getRGBA(0.02);
  return new StandStyle({ strokeStyle, fillStyle });
});
scene.add(boxHelper);

/* 围墙 */
const wallGeometry = new RectGeometry(-320, -320, 640, 640, true);
const wallStyle = new StandStyle({
  strokeStyle: "#333",
  lineWidth: 2,
});
const wallObj = new Graph2D(wallGeometry, wallStyle);
scene.add(wallObj);
/* 围墙内缩 */
const wallExtendObj = crtExtendObj(wallGeometry, radius, {
  strokeStyle: "#333",
  lineDash: [3],
});
scene.add(wallExtendObj);
function crtExtendObj(geometry: PolyGeometry, radius: number, style: StandStyleType) {
  geometry.computeSegmentNormal();
  const extendGeomtry = new PolyExtendGeometry(geometry, radius).close();
  const extendStyle = new StandStyle(style);
  return new Graph2D(extendGeomtry, extendStyle);
}
/* 围墙内缩后的包围盒 */
{
  const boundingBox = crtBoundingBox(wallExtendObj.geometry);
  scene.add(boundingBox);
}
function crtBoundingBox(geometry: PolyExtendGeometry) {
  geometry.computeBoundingBox();
  const {
    min: { x: x1, y: y1 },
    max: { x: x2, y: y2 },
  } = geometry.boundingBox;
  return new Graph2D(
    new PolyGeometry([x1, y1, x2, y1, x2, y2, x1, y2]).close(),
    new StandStyle({
      strokeStyle: "rgba(255,120,0,0.6)",
      lineWidth: 2,
      lineDash: [12, 4, 4, 4],
    })
  );
}

// 球体运动方向
const direction = new Vector2(1, 0.3).normalize();
// 射线
let ray2D = new Ray2(origin.clone(), direction);

// 碰撞反弹路径
const reboundGeometry = new PolyGeometry();
const reboundStyle = new StandStyle({
  strokeStyle: "#00acec",
  lineWidth: 2,
});
const reboundObj = new Graph2D(reboundGeometry, reboundStyle);
scene.add(reboundObj);

// 射线与包围盒的相交数据
let intersectObstractData: IntersectData[] | null = null;

// 反弹次数
const reflectNum = 29;

ani()
/* 连续动画 */
function ani() {
  // 若intersectObstractData存在,恢复其中第1个图形的样式
  if (intersectObstractData) {
    intersectObstractData[0].object.style.setOption({
      lineWidth: 1,
      lineDash: [3],
    });
  }
  // 旋转射线
  ray2D.direction.rotate(0.005);
  // 暂存射线
  const rayTem = ray2D.clone();
  // 反弹路径
  const reboundPath = [...origin];
  // 在特定的反弹次数内,让射线通过BVH选择物体
  for (let i = 0; i < reflectNum; i++) {
    intersectObstractData = intersectBVH(rayTem, rootBox);
    if (intersectObstractData) {
      const { point } = intersectObstractData[0];
      // 将交点存入reboundPath
      reboundPath.push(...point);
      // 将选中的图形的描边加粗
      intersectObstractData[0].object.style.setOption({
        lineWidth: 3,
        lineDash: [],
      });
      break;
    }

    // 射线与墙体的相交数据
    const intersectWallData = intersectObject(rayTem, wallExtendObj, true);
    if (intersectWallData) {
      const { point, normal } = intersectWallData;
      // 将交点存入reboundPath
      reboundPath.push(...point);
      // 反弹射线
      rayTem.reflect(point, normal);
      continue;
    }
  }
  // 更新反弹图形
  reboundGeometry.position = reboundPath;
  // 渲染
  scene.render();
  requestAnimationFrame(ani);
}

onMounted(() => {
  const canvas = canvasRef.value;
  if (canvas) {
    scene.setOption({ canvas });
    scene.render();
  }
});
</script>

<template>
  <canvas ref="canvasRef" :width="size.width" :height="size.height"></canvas>
</template>

<style scoped></style>

效果如下:

image-20240515234804206

当前示例中的技术点咱们都说过,剩下的都是业务逻辑,我都有详细注释,大家可以自己看。

总结

这一篇我们说了BVH的原理和代码实现,它适合在元素较多的场景里做选择。