圆形与多边形的碰撞反弹

180 阅读31分钟

源码

github.com/buglas/canv…

学习目标

  • 绘制圆形在多边形间的碰撞反弹路径

知识点

  • 射线与线段的交点
  • 射线与圆弧的交点
  • 反射方法

1-题目解析

题目:小球(圆形)会朝某一方向运动,碰到墙体时会反弹,碰到障碍物时会停止运动。请画出小球的运动路径。

此题的画法如下图所示:

image-20231121194403019

我们暂且不考虑物理因素,比如重力、摩擦力、弹力等。

碰撞与反弹是先后发生的两件事,咱们先说碰撞。

2-圆形与多边形的碰撞原理

圆形的表面是否碰撞到多边形,并不好直接计算。

所以,可以将多边形扩展圆形半径的距离,然后判断球心是否在多边形上。

这样我们把圆形简化为一点,便可以很方便的计算圆形与多边形的关系。

这时,细心的同学可能会想到,多边形在扩展的时候是有内外两个方向的,我们如何确定它往哪个方向扩展?

多边形是向其法线方向扩展的。

多边形的绘制是有方向性的,有了方向性,也就有了法线。

比如,障碍物是顺时针绘制的,这样其法线是朝外的,障碍物在扩展的时候会向外扩展;

墙体则反之,墙体逆时针绘制的,其法线朝内,墙体在扩展的时候会向内扩展。

image-20240217140130547

当扩展线上的任意点p到圆心O的距离等于零时,圆形与多边形发生碰撞。

现在圆心O是已知的,圆的运动方向也是已知的,这就得到了一条射线。

所以接下来的重点就是求射线与扩展线的交点p。

扩展线是由线段和圆弧组成的:

image-20231122113909672

因此,要求射线和线段的交点、射线和圆弧的交点。

3-射线与线段的交点

若射线与线段有交点,需满足以下条件:

  1. 射线指向线段的正面。
  2. 射线所在的直线与线段所在直线有交点。
  3. 交点在线段之上。
  4. 交点是在射线上。

3-1-射线是否指向线段的正面

image-20201219144719283

已知:

  • 线段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-与线段共线的一点是否在线段上

image-20231126121604650

已知:线段AB

求:与线段共线的一点P是否在线段AB中

解:

点P在线段AB中需满足以下条件:

|AP| <= |AB|
|BP| <= |AB|

3-4-与射线共线的一点是否在射线上

image-20231127222309890

已知:射线,源点是A,方向是v

求:与射线共线的一点P 是否在射线上

解:

点P 是否在射线上需满足以下条件:

v·AP >= 0

· 是点积的意思。

v·AP的结果就是向量AP在向量v 上的正射影乘以向量AP的长度。

当点P在射线上时,向量AP在向量v 上的正射影大于0;否则小于0。

4-射线与圆弧的交点

要求射线和圆弧的交点,可以先根据射线的特点,判断射线的源点是否在圆弧所在的圆内。

若射线源点在圆内或在圆上,那射线可能与圆弧有交点。

否则,需要在根据射线所在的直线与圆的关系来过滤它们相交的可能性。

直线与圆存在3种关系:

  • 相离:圆心到直线的距离大于半径,没有交点。
  • 相切:圆心到直线的距离等于半径,有1个交点。
  • 相交:圆心到直线的距离小于半径,有2个交点。

由上可知,相离的情况可以直接排除掉,我们要考虑相切和相交的情况。

接下来我们把这个问题引申到射线和圆弧的关系。

当射线和圆弧相切时,只要切点即在圆弧也在射线上,那切点就是射线和圆弧的交点。

当射线和圆弧相交时,因为圆弧与之前的线段一样,都有正反之分,所以射线朝向圆弧正面的交点最多只有1个。

当射线和圆弧所在圆有两个交点时,任一交点满足下面的2个条件,那此点就是射线从圆弧正面射入的交点:

  • 射线朝向圆弧正面。
  • 交点在圆弧之上。

除此之外,射线与圆弧存在交点还需要满足射线指向圆弧的条件。

接下来咱们说具体的数学算法。

image-20231122161022124

已知:

  • 圆弧:圆心为点O,半径为r,起始弧度为start,结束弧度end,旋转方向为顺时针。
  • 射线:射线的源点为M,方向是v,v是单位向量。

求:射线与圆弧的交点

解:

一,射线的源点在圆弧所在的圆内的情况。

image-20240517111229696

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

二,射线的源点在圆上或圆外的情况。

image-20231122161022124

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:

image-20231122175926099

基于点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-反射原理

当圆形与多边形碰撞时会发生反弹,我们暂不考虑复杂的物理因素,就假设这种反弹是按照镜面反射的规律走的。

这样,我们就需要解释一下反射的概念。

反射是指某种波在传播到不同物质时,在分界面上改变传播方向,并返回到原来物质中的现象。

我们接下来要说的反射是镜面反射。

镜面反射的入射角和反射角是相等的,大家可以将圆形的反射轨迹理解为光线在镜面上的反射。

举个例子说一下。

Reflection_angles

已知:

  • 镜面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-多边形扩展线的绘制原理

我们通过之前的示意图说一下扩展线的扩展方式。

image-20231121194403019

扩展线的扩展距离是圆形的半径,其扩展方式有两种:

  • 外扩,如障碍物的扩展线。障碍物是顺时针绘制的,法线朝外,扩展线沿法线方向外扩。在其拐角处会用圆弧补全。
  • 内缩,如墙体的扩展线。墙体是逆时针绘制的,法线朝内,扩展线沿法线方向内缩。在其拐角处会计算两侧扩展线的交点。

扩展线的拐角有两种状态:

image-20231125203802866

  • 圆角:相邻两条线段的夹角大于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>

效果如下:

image-20231126115642407

接下来要绘制小球的碰撞反弹路径,还需要以下对象:

  • 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则不在其范围内。

image-20231122161022124

在此还要注意圆弧的绘制方式。

圆弧的样子随其是否是逆时针绘制、起始弧度和结束弧度的大小关系而变,总共有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)
}

效果如下:

image-20231127195312749

我们可以把墙体变成异形看看:

/* 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()

效果如下:

image-20231127195555081

现在扩展线已经完成了,接下来我们需要以圆心为起点,给圆形一个运动方向,使其与扩展线做碰撞。

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'})
  )
)

渲染效果如下:

image-20231128204418335

现在我们已经连接圆心和碰撞点画出了一条蓝色线段,接下来咱们让它碰撞反弹。

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)
    }
}

效果如下:

image-20231128220022153

在上面的代码中,我控制了反弹次数,这样可以避免它无限反弹。

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()

效果如下:

image-20231128220946387

从上图来看,这种碰撞反弹是没有问题的。

扩展

之前我们在确定直线与圆的关系时,是通过圆心到直线的距离和半径的关系来确定的。

其实,我们也可以用一元二次方程的求根公式来确定直线与圆的关系。

直线的函数: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²

用求根公式解上面的一元二次方程:

image-20240225122946598

根据上面的解,可得到以下结论:

  • 当(b²-4ac)<0时,x无解,直线与圆相离;
  • 当(b²-4ac)=0时,x有1个值,直线与圆相切;
  • 当(b²-4ac)>0时,x有2个值,直线与圆相离。

注:一元二次方程的求根公式属于初中数学知识,大家可以在《北师大版-数学九年级上册》的42页找到。

28385e1e24f02d2afd09e2eab675a11

知道了x后,将其代入直线的函数r(x)=m+xd,便可求出直线与圆的交点。

若一条射线在此直线上,那我们依旧可以通过点积来判断交点是否在射线上,此原理我们之前已经说过。

总结

在这一篇我们说了圆形与多边形的碰撞、射线与线段的交点、射线与圆弧的交点、反射等知识,这些都是从事图形学相关职业必备的基础。

至于滴滴为什么要考这道题,是因为我去的部门是做无人驾驶的,他们会对物体的运动轨迹做出预判,找到最优的行驶路线。

如果大家实现了这种效果,那就算及格了,但我希望你能拿到满分,甚至120分。

所以我会在课程的最后面,告诉大家如何通过AABB包围盒和BVH 优化碰撞检测。

下一篇我们会说第三道面试题:b站弹幕效果。