源码
学习目标
- 使用BVH优化选择
知识点
- BVH
1-BVH 原理
BVH(Bounding Volume Hierarchy)是一种层次包围结构,适用于对数量较多的几何对象进行碰撞检测或光线追踪。
在BVH中,所有的几何物体都会被包在包围体里面,包围体外面还会包着一个更大的包围体,以此递归地包裹下去,最终形成的根节点会包裹着整个场景。
这种结构对于物体较多的场景中非常有效。
解释一下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>
效果如下:
解释一下其过程。
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>
效果如下:
当前示例中的技术点咱们都说过,剩下的都是业务逻辑,我都有详细注释,大家可以自己看。
总结
这一篇我们说了BVH的原理和代码实现,它适合在元素较多的场景里做选择。