源码
学习目标
- 绘制圆形在多边形间的碰撞反弹路径
知识点
- 射线与线段的交点
- 射线与圆弧的交点
- 反射方法
1-题目解析
题目:小球(圆形)会朝某一方向运动,碰到墙体时会反弹,碰到障碍物时会停止运动。请画出小球的运动路径。
此题的画法如下图所示:
我们暂且不考虑物理因素,比如重力、摩擦力、弹力等。
碰撞与反弹是先后发生的两件事,咱们先说碰撞。
2-圆形与多边形的碰撞原理
圆形的表面是否碰撞到多边形,并不好直接计算。
所以,可以将多边形扩展圆形半径的距离,然后判断球心是否在多边形上。
这样我们把圆形简化为一点,便可以很方便的计算圆形与多边形的关系。
这时,细心的同学可能会想到,多边形在扩展的时候是有内外两个方向的,我们如何确定它往哪个方向扩展?
多边形是向其法线方向扩展的。
多边形的绘制是有方向性的,有了方向性,也就有了法线。
比如,障碍物是顺时针绘制的,这样其法线是朝外的,障碍物在扩展的时候会向外扩展;
墙体则反之,墙体逆时针绘制的,其法线朝内,墙体在扩展的时候会向内扩展。
当扩展线上的任意点p到圆心O的距离等于零时,圆形与多边形发生碰撞。
现在圆心O是已知的,圆的运动方向也是已知的,这就得到了一条射线。
所以接下来的重点就是求射线与扩展线的交点p。
扩展线是由线段和圆弧组成的:
因此,要求射线和线段的交点、射线和圆弧的交点。
3-射线与线段的交点
若射线与线段有交点,需满足以下条件:
- 射线指向线段的正面。
- 射线所在的直线与线段所在直线有交点。
- 交点在线段之上。
- 交点是在射线上。
3-1-射线是否指向线段的正面
已知:
- 线段OA,其方向为a(线段是有向线段)
- 射线OB,其方向为b
求:射线OB是否指向线段OA的正面
解:
此问题需要通过向量a和向量b 的 叉乘 来解。
二维向量a和二维向量b的叉乘可以理解为:向量b在向量a的垂线上的正射投影(BD)和向量a的长度的乘积。
由此可知:
- 当a^b<0 时,射线OB指向线段OA的背面
- 当a^b>0 时,射线OB指向线段OA的正面
- 当a^b=0 时,射线OB与线段OA平行,不做考虑
扩展:此叉乘方式只适用于二维向量,若是判断三维射线是否穿过一个面的正面,需要将射线的方向与面的法线做点积运算。
3-2-射线所在直线与线段所在直线有交点
我们上一节说过,两条直线的交点,可以用直线的一般式来求。
再简单回顾一下。
直线的一般式是:Ax+By+C=0
线段所在直线的一般式
已知:两点M(x1,y1),N(x2,y2)
则直线MN的一般式为:
(y2-y1)*x-(x2-x1)*y+(x2-x1)*y1-(y2-y1)*x1=0
从上式中可以提取到A、B、C:
A=△y=y2-y1
B=-△x=-(x2-x1)
C=△x*y1-△y*x1
射线所在直线的一般式
射线转直线一般式比线段转直线一般式很简单。
已知:射线源点O(xo,yo),方向(△x、△y)
则射线所在的直线的一般式为:
△y*x-△x*y+△x*yo-△y*xo=0
从上式中可以提取到A、B、C:
A=△y
B=-△x
C=△x*yo-△y*xo
直线一般式求交的公式
p.x=(B1C2-C1B2)/(A1B2-A2B1)
p.y=(A2C1-A1C2)/(A1B2-A2B1)
3-3-与线段共线的一点是否在线段上
已知:线段AB
求:与线段共线的一点P是否在线段AB中
解:
点P在线段AB中需满足以下条件:
|AP| <= |AB|
|BP| <= |AB|
3-4-与射线共线的一点是否在射线上
已知:射线,源点是A,方向是v
求:与射线共线的一点P 是否在射线上
解:
点P 是否在射线上需满足以下条件:
v·AP >= 0
· 是点积的意思。
v·AP的结果就是向量AP在向量v 上的正射影乘以向量AP的长度。
当点P在射线上时,向量AP在向量v 上的正射影大于0;否则小于0。
4-射线与圆弧的交点
要求射线和圆弧的交点,可以先根据射线的特点,判断射线的源点是否在圆弧所在的圆内。
若射线源点在圆内或在圆上,那射线可能与圆弧有交点。
否则,需要在根据射线所在的直线与圆的关系来过滤它们相交的可能性。
直线与圆存在3种关系:
- 相离:圆心到直线的距离大于半径,没有交点。
- 相切:圆心到直线的距离等于半径,有1个交点。
- 相交:圆心到直线的距离小于半径,有2个交点。
由上可知,相离的情况可以直接排除掉,我们要考虑相切和相交的情况。
接下来我们把这个问题引申到射线和圆弧的关系。
当射线和圆弧相切时,只要切点即在圆弧也在射线上,那切点就是射线和圆弧的交点。
当射线和圆弧相交时,因为圆弧与之前的线段一样,都有正反之分,所以射线朝向圆弧正面的交点最多只有1个。
当射线和圆弧所在圆有两个交点时,任一交点满足下面的2个条件,那此点就是射线从圆弧正面射入的交点:
- 射线朝向圆弧正面。
- 交点在圆弧之上。
除此之外,射线与圆弧存在交点还需要满足射线指向圆弧的条件。
接下来咱们说具体的数学算法。
已知:
- 圆弧:圆心为点O,半径为r,起始弧度为start,结束弧度end,旋转方向为顺时针。
- 射线:射线的源点为M,方向是v,v是单位向量。
求:射线与圆弧的交点
解:
一,射线的源点在圆弧所在的圆内的情况。
1.如果|OM|<r,射线的源点在的圆内。
2.如果圆弧顺时针绘制,圆弧法线朝外,射线不会与圆弧的正面相交。
3.如果圆弧逆时针绘制,圆弧法线朝内,射线可能与圆弧的正面相交。
此时需要先计算射线与圆的交点,设此交点为B,则:B=F+d*v
- F 是圆心在射线所处的直线上的投影,即垂足,F=M+(MO·v)*v
- d=|AF|=|FB|=sqrt(r²-|OF|²)
若点B在圆弧范围内,点B就是射线与圆弧的交点,否则射线与圆弧没有交点。
star <= arctan(OB.y/OB.x) <= end
二,射线的源点在圆上或圆外的情况。
1.判断射线是否朝向圆弧。
当MO·v>=0 时,射线朝向圆弧,可能有交点;
否则,没有交点。
2.判断射线所在的直线与圆弧所在的圆形的关系。
求圆心O在射线上的投影点F:
F=M+x*v
x=MO·v
F=M+(MO·v)*v
在此大家要熟知点积的概念,向量MO点积向量v 就相当于向量MO在向量v上的正射影乘以向量v的长度。
有了垂足F后,便可以算出圆心O到直线的距离b:
b=|F-O|
根据b的值,可以判断出直线和圆的关系,这个咱们之前已经说过,忽略相离的情况,咱们说相切和相交。
3.相切时,射线与圆弧的交点。
当相切时,垂足F就是切点,需判断切点是否在圆弧上。
计算向量OF的角度,然后判断此角是否在起始弧度star和结束弧度end之间即可:
star <= arctan(OF.y/OF.x) <= end
注:我当前写的是数学公式,数学公式比代码更简洁,更便于理解。
当点F满足上面的公式,那它就是交点。
4.相交时,射线与圆弧的交点。
我们之前说过,相交时,射线与圆弧的交点要满足以下2个条件:
- 射线从圆弧正面射入。
- 交点在圆弧之上。
第二个条件我们在说相切时说过了,接下来咱们说第一个条件。
我们先算出直线与圆的交点。
根据勾股定理,可得垂足到交点的距离d:
基于点F,沿着v方向偏移-d 和d 便可得到交点A 和交点B。
A=F-d*v
B=F+d*v
假设我们已经做了A、B两点是否在圆弧之上的过滤,接下来判断哪个点是射线从圆弧正面射入的交点。
以点A为例,当点A满足以下条件时,它就是射线与圆弧的交点:
AO·v>0
点B 同理。
若圆弧是逆时针画的,需要将点积取反:
-AO·v>0
现在,关于圆形与多边形的碰撞我们就说完了,接下来我们说反射。
5-反射原理
当圆形与多边形碰撞时会发生反弹,我们暂不考虑复杂的物理因素,就假设这种反弹是按照镜面反射的规律走的。
这样,我们就需要解释一下反射的概念。
反射是指某种波在传播到不同物质时,在分界面上改变传播方向,并返回到原来物质中的现象。
我们接下来要说的反射是镜面反射。
镜面反射的入射角和反射角是相等的,大家可以将圆形的反射轨迹理解为光线在镜面上的反射。
举个例子说一下。
已知:
- 镜面mirror,其法线为normal
- 入射光线PO
- PO在O点撞击镜面
- 反射光线为OQ
- 入射角θi
- 反射角θr
反射的物理定律为:
- 入射光线PO、反射光线OQ 和法线normal 都在同一平面内
- 入射光线PO、反射光线OQ 分居法线normal 两侧
- 入射角θi等于反射角θr
- 在反射中,光路可逆。
根据反射的物理定律,我可以通过入射光线PO和法线normal,求反射光线OQ:
OQ=PO-2*(normal·PO)*normal
对于上式的由来,我就不给大家推导了,感兴趣的同学可以自己推导试试,若推导不出来再跟我说。
有了反弹方向后,我们可以以碰撞点为源点,以反射光线为方向,做一条新的射线,然后继续进行碰撞检测。
6-多边形扩展线的绘制原理
我们通过之前的示意图说一下扩展线的扩展方式。
扩展线的扩展距离是圆形的半径,其扩展方式有两种:
- 外扩,如障碍物的扩展线。障碍物是顺时针绘制的,法线朝外,扩展线沿法线方向外扩。在其拐角处会用圆弧补全。
- 内缩,如墙体的扩展线。墙体是逆时针绘制的,法线朝内,扩展线沿法线方向内缩。在其拐角处会计算两侧扩展线的交点。
扩展线的拐角有两种状态:
-
圆角:相邻两条线段的夹角大于180°时,夹角两侧扩展出的线段无交点,需用圆弧补全。
如A点所示,圆弧的端点是点A两侧的线段沿法线扩展后的端点,圆心是点A,半径是扩展距离。
-
尖角:相邻两条线段的夹角小于等于180°,顶点两侧扩展出的线段有交点,此交点就是扩展线的交点。
如点B所示,尖角的顶点是点B两侧的线段沿法线扩展后的交点。
注:在此暂不考虑一些异形的极端情况,比如扩展的距离超过了线段的长度。
现在这个题的数学原理已通,接下来我们写代码。
7-搭建场景
在当前场景中要用到的CircleGeometry和PolyGeometry 我们在热身时都说过,其代码如下:
整体代码如下:
- /examples/CollisionRebound.vue
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { Scene } from '../lmm/core/Scene'
import { Vector2 } from '../lmm/math/Vector2'
import { Ray2 } from '../lmm/math/Ray2'
import { intersectObjects } from '../lmm/physics/PhysicUtils'
import { CircleGeometry } from '../lmm/geometry/CircleGeometry'
import { StandStyle, StandStyleType } from '../lmm/style/StandStyle'
import { Graph2D } from '../lmm/objects/Graph2D'
import { PolyGeometry } from '../lmm/geometry/PolyGeometry'
import { PolyExtendGeometry } from '../lmm/geometry/PolyExtendGeometry'
// 获取父级属性
defineProps({
size: { type: Object, default: { width: 0, height: 0 } },
})
// 对应canvas 画布的Ref对象
const canvasRef = ref<HTMLCanvasElement>()
/* 场景 */
const scene = new Scene()
// 半径
const radius = 40
// 起点
const origin = new Vector2(-200, 0)
// 小球
const circleGeometry = new CircleGeometry(radius, 36)
const circleStyle = new StandStyle({ fillStyle: '#00acec' })
const circleObj = new Graph2D(circleGeometry, circleStyle)
circleObj.position.copy(origin)
circleObj.index = 3
scene.add(circleObj)
/* 围墙 */
const wallGeometry = new PolyGeometry([
-300, -300, -300, 300, 300, 300, 300, -300
]).close()
const wallStyle = new StandStyle({
strokeStyle: '#333',
lineWidth: 2,
})
const wallObj = new Graph2D(wallGeometry, wallStyle)
scene.add(wallObj)
/* 障碍物 */
const obstacleGeometry = new PolyGeometry([
20, -100, 20, 50, -20, 50, -20, -100
]).close()
const obstacleStyle = new StandStyle({ fillStyle: 'green' })
const obstacleObj = new Graph2D(obstacleGeometry, obstacleStyle)
scene.add(obstacleObj)
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>
效果如下:
接下来要绘制小球的碰撞反弹路径,还需要以下对象:
- Arc2:圆弧的数学对象,提供圆弧相关的数学算法。
- Ray2:射线的数学对象,提供射线相关的数学算法。
- PolyExtendGeometry:PolyGeometry的扩展对象。
8-Arc2
Arc2对象的整体代码如下:
- /src/lmm/math/Arc2.ts
import { Vector2 } from './Vector2'
import {formatRadian} from './MathUtils'
const PI2 = Math.PI * 2
class Arc2 {
constructor(
public x: number = 0,
public y: number = 0,
public r: number = 100,
public startAngle: number = 0,
public endAngle: number = PI2,
public counterclockwise: boolean = false
) {}
// 顶点到圆心的方向是否在圆弧范围内
isPointInRange(p: Vector2) {
const { x, y } = this
const ang = new Vector2(p.x - x, p.y - y).angle()
return this.isAngleInRange(ang)
}
// 弧度是否在圆弧范围内
isAngleInRange(ang:number){
const { counterclockwise } = this
const startAngle = formatRadian(this.startAngle)
const endAngle = formatRadian(this.endAngle)
if (counterclockwise) {
if (startAngle < endAngle) {
if (ang <= startAngle || ang >= endAngle) {
return true
}
} else {
if (ang >= endAngle && ang <= startAngle) {
return true
}
}
} else {
if (startAngle < endAngle) {
if (ang >= startAngle && ang <= endAngle) {
return true
}
} else {
if (ang <= endAngle || ang >= startAngle) {
return true
}
}
}
return false
}
}
export { Arc2 }
其中使用了formatRadian()方法对弧度格式化,以确保弧度在[0,2π]之间。
- /src/lmm/math/MathUtils .ts
function formatRadian(angle: number) {
let ang = angle % PI2
if (ang < 0) {
ang += PI2
}
return ang
}
export {
……
formatRadian
}
解释一下Arc2 中用到的方法。
isPointInRange(p: Vector2)
顶点到圆心的方向是否在圆弧范围内。
以下图为例,向量OA、向量OM的方向就在圆弧范围内,而向量OF、向量OB则不在其范围内。
在此还要注意圆弧的绘制方式。
圆弧的样子随其是否是逆时针绘制、起始弧度和结束弧度的大小关系而变,总共有2*2=4 种形态。
这4 种形态我就不举例了,大家可以自己画画看看,若不理解,再跟我说。
isAngleInRange(ang:number)
弧度是否在圆弧范围内。原理同上。
9-Ray2
Ray2 是二维射线对象,属于数学对象。其整体代码如下:
- /src/lmm/math/Ray2.ts
import { Arc2 } from './Arc2.js'
import { Line2 } from './Line2.js'
import { Matrix3 } from './Matrix3.js'
import { Segment2 } from './Segment2.js'
import { Vector2 } from './Vector2.js'
const _vector = new Vector2()
class Ray2 {
constructor(
public origin = new Vector2(),
public direction = new Vector2(1, 0)
) {}
set(origin: Vector2, direction: Vector2) {
this.origin.copy(origin)
this.direction.copy(direction)
return this
}
// 获取射线上距离origin为t的点位,t为有向距离
at(t: number, target: Vector2 = new Vector2()) {
return target.copy(this.direction).multiplyScalar(t).add(this.origin)
}
// 看向某点
lookAt(v: Vector2) {
this.direction.copy(v).sub(this.origin).normalize()
return this
}
// 在当前射线上将origin 滑动到t的位置
slide(t: number) {
this.origin.add(this.direction.clone().multiplyScalar(t))
return this
}
// point在射线上的投影
projectByPoint(point: Vector2, target: Vector2 = new Vector2()) {
// point 投影在射线上的有向距离
const directionDistance = _vector
.subVectors(point, this.origin)
.dot(this.direction)
// directionDistance 小于0时,point无法投影到射线上
if (directionDistance < 0) {
return null
}
return this.at(directionDistance, target)
}
// 射线到点的距离
distanceToPoint(point: Vector2) {
return Math.sqrt(this.distanceSqToPoint(point))
}
// 射线到点的距离的平方
distanceSqToPoint(point: Vector2) {
const directionDistance = _vector
.subVectors(point, this.origin)
.dot(this.direction)
// point behind the ray
if (directionDistance < 0) {
return this.origin.distanceToSquared(point)
}
return this.at(directionDistance).distanceToSquared(point)
}
// 射线与直线的交点,Vector2|null
intersectLine(line: Line2) {
const { origin, direction } = this
const interPoint = new Line2(
origin,
_vector.addVectors(origin, direction)
).intersect(line)
/* 没有交点 */
if (!interPoint) {
return null
}
/* 有交点 */
// 交点是否在射线上
const inRay = interPoint.clone().sub(origin).dot(direction) > 0
if (!inRay) {
return null
}
return interPoint
}
// 射线与线段的交点,Vector2|null
intersectSegment(seg: Segment2) {
const { direction } = this
/*射线是否从正面穿过有向线段 */
if (seg.vector().cross(direction) < 0) {
return null
}
/* 射线与线段的交点 */
const interPoint = this.intersectLine(seg)
if (!interPoint) {
return null
}
// 判断交点是否在线段中
const seglen = seg.lengthSq()
if (interPoint.distanceToSquared(seg.start) < seglen && interPoint.distanceToSquared(seg.end)<seglen) {
return interPoint
}
return null
}
/* 射线在圆弧正面上的交点 */
intersectArc(arc: Arc2) {
const { direction } = this
const { x, y, r} = arc
/* 圆心 */
const O = new Vector2(x, y)
/* 垂足 */
const F = this.projectByPoint(O)
if (!F) {
return null
}
/* 垂足到圆心的距离 */
const b = _vector.subVectors(F, O).length()
/* 相离 */
if (b > r) {
return null
}
/* 相切 */
if (b === r && arc.isPointInRange(F)) {
return F
}
/* 相交 */
/* 勾股定理求直角边-垂足到交点的距离 */
const d = Math.sqrt(r*r-b*b)
/* 以垂直为原点,与当前射线同向的射线 */
const FRay=new Ray2(F, direction)
/* 直线与圆相交时有2个交点 */
const A=FRay.at(d)
const B=FRay.at(-d)
/*
射线与圆弧的有效交点只有1或0个,它需要满足以下条件:
1.射线从圆弧正面射入
2.交点在圆弧之上
按照上面的两个条件,过滤交点。任意一点满足条件,便可以直接返回此点。
*/
return this.isIntersectionInArcFront(A,arc)||this.isIntersectionInArcFront(B,arc)
}
/* 判断交点是否在圆弧正面 */
isIntersectionInArcFront(intersection:Vector2,arc: Arc2){
// 顶点到圆心的方向是否在圆弧范围内。
if(!arc.isPointInRange(intersection)){
return null
}
const { direction } = this
const { counterclockwise } = arc
const o = new Vector2(arc.x, arc.y)
const m=counterclockwise?-1:1;
// 通过点积判断射线是否从圆弧的正面射入
const n=new Vector2().subVectors(o, intersection).dot(direction)
if (n*m > 0 ) {
return intersection
}
return null
}
// 射线的反弹
reflect(p: Vector2, n: Vector2) {
const { origin, direction } = this
origin.copy(p)
this.direction.subVectors(
direction,
n.clone().multiplyScalar(2 * n.dot(direction))
)
}
// 求等
equals(ray: Ray2) {
return (
ray.origin.equals(this.origin) && ray.direction.equals(this.direction)
)
}
// 翻转方向
invert() {
this.direction.multiplyScalar(-1)
return this
}
/* 矩阵变换 */
applyMatrix3(matrix:Matrix3){
const {origin,direction}=this
direction.add(origin)
origin.applyMatrix3(matrix)
direction.applyMatrix3(matrix).sub(origin).normalize()
return this;
}
/* 斜截式: y=kx+b,或者x=ky+b */
getSlopeIntercept(type:'x'|'y'='y'){
const { origin, direction } = this
const [m,n]=type==='y'?['y','x']:['x','y'];
const k=direction[m]/direction[n]
const b = origin[m] - k * origin[n]
return function (arg: number) {
return k * arg + b
}
}
/* 克隆 */
clone() {
return new Ray2().copy(this)
}
/* 拷贝 */
copy(ray: Ray2) {
this.origin.copy(ray.origin)
this.direction.copy(ray.direction)
return this
}
}
export { Ray2 }
Ray2 对象是通过源点和方向定义的,这是射线的几何概念。
Ray2 对象中的intersectLine() 、intersectSegment() 、intersectArc() 、reflect() 等方法和我们前面说的数学知识都是一致的,大家可以相互印证、理解。
10-PolyExtendGeometry
PolyExtendGeometry 是多边形扩展对象。
在当前案例里,PolyExtendGeometry可以对墙体和障碍物进行扩展,以便于小球的碰撞检测。
PolyExtend 对象的整体代码如下:
- /src/lmm/geometry/PolyExtendGeometry.ts
import { PolyGeometry } from './PolyGeometry'
import { Arc2 } from '../math/Arc2'
import { Matrix3 } from '../math/Matrix3'
import { Ray2 } from '../math/Ray2'
import { Segment2 } from '../math/Segment2'
import { Vector2 } from '../math/Vector2'
import { Geometry } from './Geometry'
/* 圆弧的数据类型 */
export type ArcType = {
origin: Vector2
startAngle: number
endAngle: number
startPosition: Vector2
endPosition: Vector2
}
/* 顶点扩展后的图形类型 */
export type ShapeElementType = Vector2 | ArcType
const PI = Math.PI
/* x,y,-x,-y 方向 */
const angles = [0, PI / 2, PI, (PI * 3) / 2, PI * 2]
class PolyExtendGeometry extends Geometry {
// 扩展目标
target: PolyGeometry
// 扩展距离
distance: number
// 图形数据集合
position: ShapeElementType[] = []
readonly type = 'PolyExtendGeometry'
constructor(target: PolyGeometry = new PolyGeometry(), distance: number = 0) {
super()
this.target = target
this.distance = distance
this.updatePosition()
}
// 更新图形
updatePosition() {
const {
target: { position, normals },
distance,
} = this
const shapeLen = position.length
// 多边形的线段扩展后的顶点集合
const points: Vector2[] = []
// 遍历线段,将其沿法线扩展
for (let i = 0; i < shapeLen; i += 2) {
// 当前线段的法线
const normal = new Vector2(normals[i], normals[i + 1])
// 下一个点
const j = (i + 2) % shapeLen
// 原线段
const p1 = new Vector2(position[i], position[i + 1])
const p2 = new Vector2(position[j], position[j + 1])
// 扩展线段
const p3 = new Ray2(p1, normal).at(distance)
const p4 = new Ray2(p2, normal).at(distance)
points.push(p3, p4)
}
const curShape: ShapeElementType[] = []
const pointsLen = points.length
// 遍历扩展后的顶点
for (let i = 0; i < pointsLen; i += 2) {
// 当前顶点前的线段
const p1 = points[(i - 2 + pointsLen) % pointsLen]
const p2 = points[(i - 1 + pointsLen) % pointsLen]
const segF = new Segment2(p1, p2)
// 当前顶点后的线段
const p3 = points[i]
const p4 = points[i + 1]
const segB = new Segment2(p3, p4)
// 线段与线段的交点
const intersection = segF.intersect(segB)
// 若当前顶点两侧扩展出的线段有交点,则当前顶点扩展出的图形是交点;否则是圆弧。
if (intersection) {
curShape.push(intersection)
} else {
const origin = new Vector2(position[i], position[i + 1])
curShape.push({
origin,
startAngle: new Vector2().subVectors(p2, origin).angle(),
endAngle: new Vector2().subVectors(p3, origin).angle(),
startPosition: p2,
endPosition: p3,
})
}
}
this.position = curShape
}
/* 创建路径 */
crtSubpath(ctx: CanvasRenderingContext2D) {
const { position,distance,closePath } = this
for (let ele of position) {
if (ele instanceof Vector2) {
ctx.lineTo(ele.x, ele.y)
} else {
const {
origin: { x, y },
startAngle,
endAngle,
} = ele
ctx.arc(x, y, distance, startAngle, endAngle)
}
}
closePath && ctx.closePath()
return this;
}
// 应用矩阵
applyMatrix3(matrix: Matrix3) {
const { target } = this
target.applyMatrix3(matrix)
this.updatePosition()
return this
}
// 包围盒
computeBoundingBox() {
const {
position,
distance,
boundingBox: { min, max },
} = this
min.set(Infinity)
max.set(-Infinity)
for (let i = 0, len = position.length; i < len; i++) {
const curShape = position[i]
if (curShape instanceof Vector2) {
this.expand(curShape)
} else {
const { origin, startAngle, endAngle, startPosition, endPosition } =
curShape
this.expand(startPosition)
this.expand(endPosition)
const arc = new Arc2(origin.x, origin.y, distance, startAngle, endAngle)
angles.forEach((angle) => {
if (arc.isAngleInRange(angle)) {
const s = Math.sin(angle)
const c = Math.cos(angle)
const p = new Vector2(distance * c, distance * s).add(origin)
this.expand(p)
}
})
}
}
return this
}
// 扩展
expand({ x, y }: Vector2) {
const {
boundingBox: { min, max },
} = this
if (min.x > x) {
min.x = x
} else if (max.x < x) {
max.x = x
}
if (min.y > y) {
min.y = y
} else if (max.y < y) {
max.y = y
}
}
// 克隆
clone() {
return new PolyExtendGeometry().copy(this)
}
// 拷贝
copy(polyExtendGeometry: PolyExtendGeometry) {
const { target, distance,boundingBox:{min,max}}=polyExtendGeometry
this.target.copy(target)
this.distance = distance;
this.updatePosition()
this.boundingBox={
min:min.clone(),
max:max.clone(),
}
return this
}
}
export { PolyExtendGeometry }
PolyExtendGeometry 中的图形由顶点和圆弧组成的,解释一下其绘图方法。
updatePosition()
扩展多边形,其生成的图形会放在position中,其绘图逻辑如下。
1.遍历多边形里的线段,然后做扩展。
const {
target: { position, normals },
distance,
} = this
const shapeLen = position.length
// 多边形的线段扩展后的顶点集合
const points: Vector2[] = []
// 遍历线段
for (let i = 0; i < shapeLen; i += 2) {
// 当前线段的法线
const normal = new Vector2(normals[i], normals[i + 1])
// 下一个点
const j = (i + 2) % shapeLen
// 原线段
const p1 = new Vector2(position[i], position[i + 1])
const p2 = new Vector2(position[j], position[j + 1])
// 扩展线段
const p3 = new Ray2(p1, normal).at(distance)
const p4 = new Ray2(p2, normal).at(distance)
points.push(p3, p4)
}
2.在扩展后的线段间补全圆弧,或者计算交点。
const curShape: ShapeElementType[] = []
const pointsLen = points.length
// 遍历扩展后的顶点
for (let i = 0; i < pointsLen; i += 2) {
// 当前顶点前的线段
const p1 = points[(i - 2 + pointsLen) % pointsLen]
const p2 = points[(i - 1 + pointsLen) % pointsLen]
const segF = new Segment2(p1, p2)
// 当前顶点后的线段
const p3 = points[i]
const p4 = points[i + 1]
const segB = new Segment2(p3, p4)
// 线段与线段的交点
const intersection = segF.intersect(segB)
// 若当前顶点两侧扩展出的线段有交点,则当前顶点扩展出的图形是交点;否则是圆弧。
if (intersection) {
curShape.push(intersection)
} else {
const origin = new Vector2(position[i], position[i + 1])
curShape.push({
origin,
startAngle: new Vector2().subVectors(p2, origin).angle(),
endAngle: new Vector2().subVectors(p3, origin).angle(),
startPosition: p2,
endPosition: p3,
})
}
}
this.position = curShape
其中涉及的数学方法咱们前面都说过,我就不再赘述,大家若有不理解的再跟我说。
crtSubpath()
绘制路径,用lineTo() 画线,用arc() 画圆弧:
applyMatrix3()
使用矩阵变换几何体。
computeBoundingBox()
计算几何体的包围盒,其原理就是基于图形的顶点向外扩展出包围图形的盒子。
由于当前几何体中的圆弧是用数学方法绘制的,没有顶点,所以需要从上下左右4个方向上探测其边界点。
4个方向如下:
const angles = [0, PI / 2, PI, (PI * 3) / 2, PI * 2]
探测方法如下:
const arc = new Arc2(origin.x, origin.y, distance, startAngle, endAngle)
angles.forEach((angle) => {
if (arc.isAngleInArc(angle)) {
const s = Math.sin(angle)
const c = Math.cos(angle)
const p = new Vector2(distance * c, distance * s).add(origin)
this.expand(p)
}
})
expand({ x, y }: Vector2)
基于图形的顶点向外扩展出包围图形的盒子。
11-绘制扩展线
在场景中实例化PolyExtend 对象。
- /src/examples/CollisionRebound.vue
/* 围墙内缩 */
const wallExtendObj = crtExtendObj(wallGeometry, radius, {
strokeStyle: '#333',
lineDash: [3],
})
scene.add(wallExtendObj)
/* 障碍物外扩 */
const obstacleExtendObj = crtExtendObj(obstacleGeometry, radius, {
strokeStyle: 'green',
lineDash: [3],
})
obstacleExtendObj.name = 'obstacle'
scene.add(obstacleExtendObj)
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 wallGeometry = new PolyGeometry([
-300, -300, -300, 300, 300, 300, 300, -300
]).close() */
const wallGeometry = new PolyGeometry([
-300, -300,
-300, 300,
300, 300,
300, 100,
150, 200,
300, -300,
]).close()
效果如下:
现在扩展线已经完成了,接下来我们需要以圆心为起点,给圆形一个运动方向,使其与扩展线做碰撞。
12-射线与多个物体的求交
求交的过程就是碰撞检测的过程,因为碰撞是属于物理的范畴,所以我把碰撞方法放到了PhysicUtils.ts 文件中。
当然,我们也可以像three.js 那样把碰撞放到射线中,这就看个人需求和爱好了。
碰撞的整体代码如下:
- /src/lmm/physics/PhysicUtils.ts
import { BoundingBox, Geometry } from '../geometry/Geometry'
import { PolyExtendGeometry, ShapeElementType } from '../geometry/PolyExtendGeometry'
import { PolyGeometry } from '../geometry/PolyGeometry'
import { Arc2 } from '../math/Arc2'
import { Ray2 } from '../math/Ray2'
import { Segment2 } from '../math/Segment2'
import { Vector2 } from '../math/Vector2'
import { Graph2D } from '../objects/Graph2D'
import { StandStyle } from '../style/StandStyle'
import { BVHBox } from './BVHBox'
/* 相交物体的类型,当前只有PolyExtend类型,后续可扩展。 */
type ObjectForIntersect = Graph2D<Geometry,StandStyle>
/* 相交的数据 */
export type IntersectData = {
// 射线原点到交点的距离
distance: number
// 交点
point: Vector2
// 相交的物体
object: ObjectForIntersect
// 交点处的法线
normal: Vector2
}
/* 求交的结果 */
type IntersectResult = IntersectData | null
/* 图元类型 */
type Path = Segment2 | Arc2
/* 最近交点数据的类型 */
type NearestDataCom = {
// 距离
distance: number
// 点位
point: Vector2 | null
}
type NearestData1 = NearestDataCom&{
// 相交的元素
path: Segment2 | Arc2 | null
}
type NearestData2 = NearestDataCom&{
// 相交的元素
path: Segment2 | null
}
/* 不同的物体会有不同的求交方式 */
const intersectLib = {
PolyGeometry:(ray: Ray2, object:Graph2D<PolyGeometry,StandStyle>): IntersectResult => {
const { geometry:{position} } = object
let len = position.length
// 最近的交点数据
const nearestData: NearestData2 = {
distance: Infinity,
point: null,
path: null,
}
/* 遍历PolyGeometry图形中的线段 */
for(let i=0;i<len;i+=2){
const j=(i+2)%len
let curPath = new Segment2(
new Vector2(position[i],position[i+1]),
new Vector2(position[j],position[j+1])
)
let curPoint = ray.intersectSegment(curPath)
updateNearestData(nearestData, ray, curPoint, curPath)
}
const { distance, point, path } = nearestData
if (point && path) {
// 法线
return {
distance,
point,
object,
normal:path.normal(),
}
}
return null
},
PolyExtendGeometry: (ray: Ray2, object:Graph2D<PolyExtendGeometry,StandStyle>) => {
const {geometry:{position,distance:extendDistance}}=object
let len = position.length
// 最近的交点数据
const nearestData: NearestData1 = {
distance: Infinity,
point: null,
path: null,
}
/* 遍历PolyExtend图形中的线段和圆弧 */
for (let i = 0; i < len; i++) {
let curPoint: Vector2 | null = null
let curPath: Segment2 | Arc2
// 当前图元,顶点或圆弧
const ele1 = position[i]
// 下一个图元,顶点或圆弧
const ele2 = position[(i + 1) % len]
const isSegment = ele1 instanceof Vector2
if (!isSegment) {
/* 圆弧求交 */
const {
origin: { x, y },
startAngle,
endAngle,
} = ele1
curPath = new Arc2(x, y, extendDistance, startAngle, endAngle)
curPoint = ray.intersectArc(curPath)
updateNearestData(nearestData, ray, curPoint, curPath)
}
/* 线段求交 */
curPath = new Segment2(getPos1(ele1), getPos2(ele2))
curPoint = ray.intersectSegment(curPath)
updateNearestData(nearestData, ray, curPoint, curPath)
}
const { distance, point, path } = nearestData
if (point && path) {
// 法线
let normal =
path instanceof Arc2
? new Vector2()
.subVectors(point, new Vector2(path.x, path.y))
.normalize()
: path.normal()
return {
distance,
point,
object,
normal,
}
}
return null
},
}
/* 更新最近的交点数据 */
function updateNearestData(
nearestData: NearestData1,
ray: Ray2,
curPoint: Vector2 | null,
curPath: Path | null
) {
if (curPoint) {
// 取最近的交点
const curDistance = new Vector2().subVectors(curPoint, ray.origin).length()
if (curDistance < nearestData.distance) {
nearestData.distance = curDistance
nearestData.path = curPath
nearestData.point = curPoint
}
}
}
/* 射线与包围盒的相交 */
function intersectBoundingBox(ray: Ray2, boundingBox: BoundingBox) {
const {
origin: O,
origin: { x: ox, y: oy },
direction: v,
direction: { x: dx, y: dy },
} = ray
const {
min: { x: minX, y: minY },
max: { x: maxX, y: maxY },
} = boundingBox
if (dy === 0) {
const b1 = oy > minY && oy < maxY
const b2 =
new Vector2(minX - ox, 0).dot(v) > 0 ||
new Vector2(maxX - ox, 0).dot(v) > 0
if (b1 && b2) {
return true
}
} else if (dx === 0) {
const b1 = ox > minX && ox < maxX
const b2 =
new Vector2(0, minY - oy).dot(v) > 0 ||
new Vector2(0, maxY - oy).dot(v) > 0
if (b1 && b2) {
return true
}
} else {
// 因变量为y的斜截式
const interceptY = ray.getSlopeIntercept('y')
// 因变量为x的斜截式
const interceptX =ray.getSlopeIntercept('x')
// 射线与包围盒的四条边所在的直线的4个交点减O
const OA1 = new Vector2(minX, interceptY(minX)).sub(O)
const OA2 = new Vector2(maxX, interceptY(maxX)).sub(O)
const OB1 = new Vector2(interceptX(minY), minY).sub(O)
const OB2 = new Vector2(interceptX(maxY), maxY).sub(O)
// OA1、OA2 中的极小值Amin和极大值Amax
const [Amin, Amax] = [OA1.dot(v), OA2.dot(v)].sort((a, b) =>
a > b ? 1 : -1
)
// OB1、OB2 中的极小值Bmin和极大值Bmax
const [Bmin, Bmax] = [OB1.dot(v), OB2.dot(v)].sort((a, b) =>
a > b ? 1 : -1
)
if (Math.max(Amin, Bmin) < Math.min(Amax, Bmax)) {
return true
}
}
return false
}
/*
与单个物体的求交
一条射线可能与一个物体存在多个交点。
此方法返回离射线原点最近的点。
*/
function intersectObject(
ray: Ray2,
object: ObjectForIntersect,
useBoundingBox: boolean = false
): IntersectResult {
const { geometry:{boundingBox,type} } = object
// 先与包围盒进行碰撞检测
if (useBoundingBox && !intersectBoundingBox(ray, boundingBox)) {
// console.log('未碰撞到包围盒', object.name)
return null
}
// 再与具体图形做碰撞检测
if (intersectLib[type]) {
return intersectLib[type](ray, object)
}
return null
}
/* 获取当前点或当前圆弧的endPosition */
function getPos1(ele: ShapeElementType) {
return ele instanceof Vector2 ? ele : ele.endPosition
}
/* 获取当前点或当前圆弧的startPosition */
function getPos2(ele: ShapeElementType) {
return ele instanceof Vector2 ? ele : ele.startPosition
}
/* 射线与多个物体的求交
ray 射线
objects 求交的图形集合
useBoundingBox 是否使用包围盒求交
sort 是否基于交点到射线源点的距离对求交结果排序
*/
function intersectObjects(
ray: Ray2,
objects: ObjectForIntersect[],
useBoundingBox: boolean = false,
sort:boolean=true
): IntersectData[] | null {
const arr: IntersectData[] = []
objects.forEach((obj) => {
const intersectResult = intersectObject(ray, obj, useBoundingBox)
intersectResult && arr.push(intersectResult)
})
if (arr.length) {
// 按照相交距离排序
sort&&arr.sort((a, b) => a.distance - b.distance)
return arr
}
return null
}
/* 射线与BVH包围盒的相交 */
function intersectBVHBox(ray: Ray2, rootBox: BVHBox) {
// 相交数据
const arr: IntersectData[] = []
rootBox.traverse(
(box) => {
/* 若box没有子节点,与此box中的图形做求交运算 */
if(!box.children.length){
const objs=intersectObjects(ray,box.objects,true,false)
objs&&arr.push(...objs)
}
},
(box) => intersectBoundingBox(ray, box)
)
if (arr.length) {
// 按照相交距离排序
arr.sort((a, b) => a.distance - b.distance)
return arr
}
return null
}
export { intersectObject,intersectObjects,intersectBoundingBox,intersectBVHBox }
当前文件导出了4个方法:
intersectObject 和intersectObjects 分别是选择单个物体和多个物体的方法,这是我们这一篇要说的。
intersectBoundingBox 是选择AABB包围盒的方法;
intersectBVHBox 是使用BVH优化选择的方法。
intersectBoundingBox 和intersectBVHBox方法我会放在另外的两篇文章里细说。
intersectObject(ray: Ray2,object: ObjectForIntersect,useBoundingBox: boolean = false)
选择单个物体,对于不同类型的物体可能会有不同的选择方法。
-
ray:用于选择物体的射线,Ray2类型
-
object:要选择的物体,ObjectForIntersect 类型,当前我只给了其Graph2D类型,后续可扩展。
type ObjectForIntersect = Graph2D<Geometry,StandStyle>
-
useBoundingBox:是否使用AABB包围盒优化选择
intersectObject 会根据object的类型从intersectLib 中调用不同的选择方法。
function intersectObject(
ray: Ray2,
object: ObjectForIntersect,
useBoundingBox: boolean = false
): IntersectResult {
const { geometry:{boundingBox,type} } = object
……
if (intersectLib[type]) {
return intersectLib[type](ray, object)
}
return null
}
intersectLib
当前intersectLib 中只有PolyGeometry和PolyExtendGeometry 的选择方法,后期可根据需求再做扩展。
在我们当前的案例里,要对PolyExtendGeometry 对象做选择,所以先看PolyExtendGeometry 对象的选择方法。
const intersectLib = {
……
PolyExtendGeometry: (ray: Ray2, object:Graph2D<PolyExtendGeometry,StandStyle>) => {
const {geometry:{position,distance:extendDistance}}=object
let len = position.length
// 最近的交点数据
const nearestData: NearestData1 = {
distance: Infinity,
point: null,
path: null,
}
/* 遍历PolyExtend图形中的线段和圆弧 */
for (let i = 0; i < len; i++) {
let curPoint: Vector2 | null = null
let curPath: Segment2 | Arc2
// 当前图元,顶点或圆弧
const ele1 = position[i]
// 下一个图元,顶点或圆弧
const ele2 = position[(i + 1) % len]
const isSegment = ele1 instanceof Vector2
if (!isSegment) {
/* 圆弧求交 */
const {
origin: { x, y },
startAngle,
endAngle,
} = ele1
curPath = new Arc2(x, y, extendDistance, startAngle, endAngle)
curPoint = ray.intersectArc(curPath)
updateNearestData(nearestData, ray, curPoint, curPath)
}
/* 线段求交 */
curPath = new Segment2(getPos1(ele1), getPos2(ele2))
curPoint = ray.intersectSegment(curPath)
updateNearestData(nearestData, ray, curPoint, curPath)
}
const { distance, point, path } = nearestData
if (point && path) {
// 法线
let normal =
path instanceof Arc2
? new Vector2()
.subVectors(point, new Vector2(path.x, path.y))
.normalize()
: path.normal()
return {
distance,
point,
object,
normal,
}
}
return null
},
}
其获取射线与图形交点的逻辑分两步:
1.获取射线与图形的所有交点。
在PolyExtendGeometry中,需计算射线与线段、圆弧的交点,且此交点可能有多个,比如”$“图形,它中间的|与s 有3个交点。
射线与圆弧的求交方法如下:
if (!(ele1 instanceof Vector2)) {
/* 圆弧求交 */
const {
origin: { x, y },
startAngle,
endAngle,
} = ele1
curPath = new Arc2(x, y, extendDistance, startAngle, endAngle)
curPoint = ray.intersectArc(curPath)
updateNearestData(nearestData, ray, curPoint, curPath)
}
射线与线段的求交方法如下:
curPath = new Segment2(getPos1(ele1), getPos2(ele2))
curPoint = ray.intersectSegment(curPath)
updateNearestData(nearestData, ray, curPoint, curPath)
2.取所有交点中最近的交点,方法如下:
/* 更新最近的交点数据 */
function updateNearestData(nearestData:NearestData,ray:Ray2, curPoint:Vector2|null,curPath:Path|null){
if(curPoint){
// 取最近的交点
const curDistance=new Vector2().subVectors(curPoint,ray.origin).length()
if(curDistance<nearestData.distance){
nearestData.distance=curDistance
nearestData.path=curPath
nearestData.point=curPoint
}
}
}
3.返回的交点数据。
const { distance, point, path } = nearestData
if (point && path) {
// 法线
let normal =
path instanceof Arc2
? new Vector2()
.subVectors(point, new Vector2(path.x, path.y))
.normalize()
: path.normal()
return {
distance,
point,
object,
normal,
}
}
交点数据如下:
- distance 交点到射线源点的距离
- point 交点。
- object 交点所在的对象。
- normal 碰撞点所在的法线,可配合入射角计算反弹方向。
intersectObjects(ray: Ray2,objects: ObjectForIntersect[], useBoundingBox:boolean= false,sort:boolean=true)
射线与多个物体的求交方法,返回射线与多个物体的交点集合,若没有交点会返回null。
- ray 射线。
- objects 求交的图形集合。
- useBoundingBox 是否使用包围盒求交。
- sort 是否基于交点到射线源点的距离对求交结果排序。
13-碰撞测试
1.以球心为原点,自定义一个方向,实例化一条射线。
// 小球运动方向
const direction = new Vector2(1, 1).normalize()
// 射线
let ray = new Ray2(origin.clone(), direction)
2.准备一个反弹路径的顶点集合,其中第一个点是球心。
const reboundPath = [...origin]
3.计算射线与墙体和障碍物的交点,若交点存在则将其push到reboundPath中。
const intersectData=intersectObjects(ray,[wallExtendObj,obstacleExtendObj])
if(intersectData){
const {point}=intersectData[0]
reboundPath.push(...point)
}
4.根据reboundPath 绘制一条路径。
scene.add(
new Graph2D(
new PolyGeometry(reboundPath),
new StandStyle({strokeStyle:'#00acec'})
)
)
渲染效果如下:
现在我们已经连接圆心和碰撞点画出了一条蓝色线段,接下来咱们让它碰撞反弹。
14-碰撞反弹
我们可以用Ray2对象的reflect 方法进行反弹测试,代码如下:
// 小球运动方向
const direction = new Vector2(1, 1).normalize()
// 射线
let ray = new Ray2(origin.clone(), direction)
// 反弹路径的顶点集合
const reboundPath = [...origin]
// 反弹次数
const reflectNum = 29
// 暂存的反弹射线
const rayTem = ray.clone()
for (let i = 0; i < reflectNum; i++) {
// 交点数据
const intersectData = intersectObjects(
rayTem,
[wallExtendObj, obstacleExtendObj],
true
)
if (intersectData) {
const { object, point, normal } = intersectData[0]
reboundPath.push(...point)
// 若碰撞到障碍物,不再反弹
if (object.uuid === obstacleExtendObj.uuid) {
break
}
// 否则,继续反弹
rayTem.reflect(point, normal)
}
}
效果如下:
在上面的代码中,我控制了反弹次数,这样可以避免它无限反弹。
const reflectNum = 29
for (let i = 0; i < reflectNum; i++) {
// 交点数据
const intersectData = intersectObjects(
rayTem,
[wallExtendObj, obstacleExtendObj],
)
……
}
当射线碰撞到中间的障碍物时,会跳出for循环。
if (object.uuid === obstacleExtendObj.uuid) {
break
}
当射线碰到了墙体,它会改变方向。
rayTem.reflect(point, normal)
reflect()方法会将射线rayTem 的原点设置为point,方向则是它基于碰撞点和法线反弹的方向。
接下来,我们可以修改射线的初始方向,看一下其效果。
const direction = new Vector2(1.2, 1).normalize()
效果如下:
从上图来看,这种碰撞反弹是没有问题的。
扩展
之前我们在确定直线与圆的关系时,是通过圆心到直线的距离和半径的关系来确定的。
其实,我们也可以用一元二次方程的求根公式来确定直线与圆的关系。
直线的函数:r(x)=m+xd
- r(x) 直线上任意一点,向量,因变量
- x 距离,在光线中也可以代表时间,实数,自变量
- m 直线上一点,向量,常量
- d 方向,向量,常量
圆的方程式:(p-o)²-r²=0
- p 圆上任意一点,未知
- o 圆心,已知
- r 半径,已知
当直线和圆相交时,圆和直线必然存在公共点,即:
r(x)=p
(m+xd-o)²-r²=0
合并上面方程的同类项,可得:
ax²+bx+c=0
- a=d·d
- b=2(m-c)·d
- c=(m-o)·(m-o)-r²
用求根公式解上面的一元二次方程:
根据上面的解,可得到以下结论:
- 当(b²-4ac)<0时,x无解,直线与圆相离;
- 当(b²-4ac)=0时,x有1个值,直线与圆相切;
- 当(b²-4ac)>0时,x有2个值,直线与圆相离。
注:一元二次方程的求根公式属于初中数学知识,大家可以在《北师大版-数学九年级上册》的42页找到。
知道了x后,将其代入直线的函数r(x)=m+xd,便可求出直线与圆的交点。
若一条射线在此直线上,那我们依旧可以通过点积来判断交点是否在射线上,此原理我们之前已经说过。
总结
在这一篇我们说了圆形与多边形的碰撞、射线与线段的交点、射线与圆弧的交点、反射等知识,这些都是从事图形学相关职业必备的基础。
至于滴滴为什么要考这道题,是因为我去的部门是做无人驾驶的,他们会对物体的运动轨迹做出预判,找到最优的行驶路线。
如果大家实现了这种效果,那就算及格了,但我希望你能拿到满分,甚至120分。
所以我会在课程的最后面,告诉大家如何通过AABB包围盒和BVH 优化碰撞检测。
下一篇我们会说第三道面试题:b站弹幕效果。