专业编程基础技术教程

网站首页 > 基础教程 正文

Canvas 之向量数学

ccvgpt 2024-08-16 14:58:18 基础教程 13 ℃

本文所有知识点均来源于《图形渲染实战 2D架构设计与实现》这本书,作者关于向量数学知识点的讲解是我目前看到的最全面、最容易理解的。这里只是抛砖引玉,感兴趣请阅读书籍

1. 概念

向量的定义:向量是具有方向(Direction)和大小(Length / Magnitude)的空间变量。

Canvas 之向量数学

向量的大小:已知一个向量a = [ x , y ],那么该向量的大小可以使用|| a ||来表示,其值为一个标量(Scalar),可以由Math.sqrt(x^2 + y^2)这个公式计算得出。

2.向量加减法

2.1加法

在图6.2中,向量a(实线且大小为200)与向量b(实线且大小为282.84)相加的几何解释就是:平移向量,使向量b的尾部(向量b的圆点部分)连接向量a的头部(向量a箭头部分),接着从向量a的尾部向向量b的头部画一个向量(大小为446.21),该向量就是向量a + b形成的新的向量,这就是向量加法的三角形法则。而在图6.2所示的向量加法几何特性图示中,使用的是向量加法的平行四边形法则绘制的。

向量加法很有用。举个例子,在力学中,如果有两个力(向量)同时施加在某个物体上,那么该物体受到的合力就是这两个力(向量)相加。

2.2减法

向量减法的几何含义,如图6.3所示,可以固定任意一个向量,平移另外一个向量,让两个向量的尾部重合,此时如果是:会发现,向量的减法是具有方向性的,并不满足交换律。

  • 向量a(实线且大小为200)减去向量b(实线且大小为282.84),则从向量b(实线且大小为282.84)的头部向着向量a(实线且大小为200)的头部画一个新的向量(粗线且大小为200),如图6.3左图所示。[插图]图6.3 向量减法
  • 向量b(实线且大小为282.84)减去向量a(实线且大小为200),则从向量a(实线且大小为200)的头部向着向量b(实线且大小为282.84)的头部画一个新的向量(粗线且大小为200),如图6.3右图所示。

2.3向量与标量乘法

向量与标量不能相加,但是它们能相乘。当一个标量和一个向量相乘时,将得到一个新的向量,该向量与原向量平行,但长度不同或方向相反。如图6.5所示,画布中心左侧的向量的方向都是相同的(平行),向量的大小则以每20个单位递增。而画布中心右侧的向量,方向相反(仍旧平行),其大小也是以每20个单位递增。

向量与标量相乘的本质是缩放向量,因此实现的静态方法名为scale。现在大家应该知道标量的英文为什么叫Scalar了,因为标量(Scalar)用来缩放(Scale)向量(Vector)


2.4向量的点乘

向量能与标量相乘,向量也能和向量相乘。两个向量相乘被称为点乘(也常称为点积或内积)

两个向量a和b,夹角为θ,根据余弦定律:

|| a - b ||2= || a ||2+ || b ||2-2 || a || || b || cosθ。

将上述表达式的左侧|| a - b ||2展开,写成|| a ||2+ || b ||2-2 ( a·b ),则可以得到:

|| a ||2+ || b ||2-2 ( a·b ) = || a ||2+ || b ||2-2 || a || || b || cosθ。

从而就可以导出如下公式:

a·b = || a || || b || cosθ

根据上面的公式,可以写成如下形式:

cosθ = a·b / ( || a || || b || ) 其中 ( || a || || b || ) 的值总是正数。


由此得到如下重要的信息:

  • 当a·b > 0时,向量a和b的夹角θ为锐角(因为0 <= θ < Math . PI / 2的余弦值大于0),可以认为两个向量方向基本相同,如图6.6左侧图示。
  • 当a·b = 0时,向量a和b的夹角θ为直角(因为Math . PI / 2的余弦值等于0),可以认为两个向量相互垂直,如图6.6中间所示的图示。
  • 当a·b < 0时,向量a和b的夹角θ为钝角(因为Math . PI / 2 < θ <= Math .PI ]的余弦值小于0),可以认为两个向量方向基本相反,如图6.6右侧图示。
  • 如果向量a、b中任意一个向量为零向量,则a·b的结果也为0,这意味着零向量和任意其他向量都是相互垂直的。


2.5向量夹角

根据这个公式:cosθ = a·b / ( || a || || b || ),那么能够很容易计算出向量a与向量b之间的夹角,伪代码如下:

  // 根据公式:cosθ = a·b / ( || a || || b || ),计算向量a和向量b的夹角
  // 根据isRadian参数的取值来判断是返回弧度还是返回角度
  public static getAngle(a: Vec2, b: Vec2, isRadian: boolean = false): number {
    let dot: number = Vec2.dotProduct(a, b);
    let radian: number = Math.acos(dot / (a.length * b.length));
    if (isRadian === false) {
      radian = Math2D.toDegree(radian);
    }
    return radian;
  }

2.6向量朝向

为了与夹角区分,使用朝向来表示物体的方向。具体代码如下:

  // 计算两个向量的连起来与x轴的夹角
  public static getOrientation(
    from: Vec2,
    to: Vec2,
    isRadian: boolean = false
  ): number {
    let diff: Vec2 = Vec2.difference(to, from);
    let radian = Math.atan2(diff.y, diff.x);
    if (isRadian === false) {
      radian = Math2D.toDegree(radian);
    }
    return radian;
  }


3.向量投影Demo

背景描述:当鼠标指针的位置在向量的区域范围之外(如图6.7所示),则正常显示该向量。但当鼠标指针的位置移动到向量区域范围内,则会加粗显示该向量,并且会:

(1)标记出鼠标指针位置(圆圈表示)、坐标信息,以及线段起点到鼠标指针处的向量。

(2)标记出鼠标指针位置在向量上的投影点(圆圈),坐标信息,以及从投影点到鼠标指针位置之间的向量。

(3)标记出鼠标指针位置与线段起点之间以角度表示的夹角。

(4)所有的坐标信息是相对全局坐标系原点(左上角)的偏移表示。


3.1向量投影算法

图6.7和图6.8所示的效果是经典的向量投影算法。简单地说,就是将一个点(鼠标位置表示)投影到由起点和终点所形成的向量上。该算法比较常用,伪代码如下:

 /**
   * 判断点是否在线上
   * @param pt
   * @param start
   * @param end
   * @param closePoint
   * @returns
   */
  public static projectPointOnLineSegment(
    pt: vec2,
    start: vec2,
    end: vec2,
    closePoint: vec2
  ): boolean {
    let v0: vec2 = vec2.create();
    let v1: vec2 = vec2.create();
    let d: number = 0;

    // 向量起点到鼠标位置的方向向量
    vec2.difference(pt, start, v0);
    // 向量起点到向量终点的方向向量
    vec2.difference(end, start, v1);
    // 原向量变成单位向量,并返回原向量的长度
    d = v1.normalize();
    // 将v0投影到v1上,获取投影长度
    // v0*v1 = ||v0|| * ||v1|| * cosθ
    // v1是单位向量,所有 ||v1|| = 1
    // 于是 v0*v1 = ||v0|| * cosθ,也就是v0在v1上的投影长度
    let t: number = vec2.dotProduct(v0, v1);

    // 如果t < 0,说明鼠标在起始点之外,返回起始点
    if (t < 0) {
      closePoint.x = start.x;
      closePoint.y = start.y;
      return false;
    } else if (t > d) { // 投影长度 > 线段长度,说明鼠标位置超过线段终点范围
      closePoint.x = end.x;
      closePoint.y = end.y;
      return false;
    } else {
      // 鼠标点位于线段中间
      // 使用scaleAdd计算出相对于全局坐标的坐标偏远信息
      // start 起点向量
      // v1 * t 投影向量
      // start + v1(单位向量)*t(标量) 相对于全局坐标的向量
      vec2.scaleAdd(start, v1, t, closePoint);
      return true;
    }
  }


3.2向量的两种夹角

为了更好地了解getOrientation(向量朝向)和getAngle(向量夹角)方法之间的区别,参考如图6.10所示的效果,可以知道:

  • getOrientation方法内部使用的是Math类的atan2方法,atan2方法返回的总是与x轴正方向向量之间的夹角,其取值范围为[ - Math . PI , Math . PI ]之间。
  • getAngle方法内部使用Math类的acos方法,acos方法返回的是两个向量之间的夹角,其返回值的取值范围为[ 0 , Math . PI ]。
  • 可以将getOrientation看作绝对方向的表示,能唯一地确定物体的方向。而getAngle返回的是相对方向,由于其取值范围,无法确定角度的旋转方向(是逆时针旋转还是顺时针旋转)。

3.3碰撞检测算法

3.3.1点与圆的碰撞检测

如果一个点在圆的半径范围之内,则说明发生了碰撞

  /**
   * 判断坐标是否在圆内部
   * @param pt 鼠标坐标
   * @param center 圆中心坐标
   * @param radius 半径
   * @returns
   */
  public static isPointInCircle(
    pt: vec2,
    center: vec2,
    radius: number
  ): boolean {
    let diff: vec2 = vec2.difference(pt, center);
    let len2: number = diff.squaredLength;

    // 避免使用Math.sqrt方法
    if (len2 <= radius * radius) {
      return true;
    }
    return false;
  }


3.3.2点与线段的碰撞检测

点与线段的碰撞检测是一个比较基础和重要的算法,该算法可以由两部分组成:

  • 用projectPointOnLineSegment方法确定一个点是否在一条线段的表示区间,并且返该点的投影点,前面实现了该方法。
  • 再调用isPointInCircle方法,该方法判断一个点是否和一个圆发生碰撞。
  /**
   * 判断坐标是否在线附近
   * @param pt 鼠标位置
   * @param start 线段起始点
   * @param end 线段终止点
   * @param radius 碰撞半径
   * @returns
   */
  public static isPointOnLineSegment(
    pt: vec2,
    start: vec2,
    end: vec2,
    radius: number = 2
  ): boolean {
    let closePt: vec2 = vec2.create();
    if (Math2D.projectPointOnLineSegment(pt, start, end, closePt) === false) {
      return false;
    }
    return Math2D.isPointInCircle(pt, closePt, radius);
  }

3.3.3点与矩形的碰撞检测

关于点与矩形的碰撞检测算法非常简单

 /**
   * 判断坐标是否在矩形内部
   * @param ptX 鼠标位置
   * @param ptY 鼠标位置
   * @param x 矩形左上角
   * @param y 矩形左上角
   * @param w 矩形宽度
   * @param h 矩形高度
   * @returns
   */
  public static isPointInRect(
    ptX: number,
    ptY: number,
    x: number,
    y: number,
    w: number,
    h: number
  ): boolean {
    if (ptX >= x && ptX <= x + w && ptY >= y && ptY <= y + h) {
      return true;
    }
    return false;
  }

3.3.4点与椭圆的碰撞检测

假设椭圆的中心点定义的坐标值为[ centerX,centerY ],并且半径分别为[ radiusX , radiusY ],在这种情况下,一个点P ( pX ,pY )如果在椭圆的内部,那么要满足如下公式:

有了上述公式,就可以实现isPointInEllipse方法。

 /**
   * 判断坐标是否在椭圆内部
   * @param ptX
   * @param ptY
   * @param centerX
   * @param centerY
   * @param radiusX
   * @param radiusY
   * @returns
   */
  public static isPointInEllipse(
    ptX: number,
    ptY: number,
    centerX: number,
    centerY: number,
    radiusX: number,
    radiusY: number
  ): boolean {
    let diffX = ptX - centerX;
    let diffY = ptY - centerY;
    let n: number =
      (diffX * diffX) / (radiusX * radiusX) +
      (diffY * diffY) / (radiusY * radiusY);
    return n <= 1.0;
  }


3.3.5点与三角形的碰撞检测

如图6.11所示为点与三角形的关系,从图中可以知道,点P与[ v0 , v1 , v2 ]形成的三角形之间的关系有两种,要么点P在三角形内部,要么点P在三角形的外部。


来观察一下它们之间的区别,你会发现:

  • 如果点P在[ v0 , v1, v2 ]形成的三角形内部,那么三角形的三个顶点和P形成的三个子三角形[ v0 , v1 , p ]、[ v1 , v2 , p ]和[ v2 , v0 , p]的顶点顺序都是按照顺时针排列的。
  • 如果点P在[ v0 , v1, v2 ]形成的三角形外部,那么三角形的三个顶点和P形成的三个子三角形[ v0 , v1 , p ]、[ v1 , v2 , p ]和[ v2 , v0 , p]的顶点顺序总有一个不是按照顺时针顺序排列的,例如图6.11右侧图像中,[ v2 , v0 , p ]形成的子三角形是非顺时针(逆时针)排列的。
  • 更加通用的算法描述是,如果点P与[ v0 , v1 , v2 ]形成的三个子三角形的顶点排列顺序一致,那么该点P肯定在[ v0 , v1 , v2 ]形成的三角形的内部,否则该点P就在三角形的外部。

3.3.5.1向量叉乘

向量叉乘比较特别,只能使用3D向量,现在假设有两个3D向量 a=[x0,y0, z0]和b=[x1, y1, z1],那么

为了将2D向量vec2以3D向量的形式来表示,可以将3D向量的z分量设置为0,例如a=[x0, y0,0], b=[x1, y1,0],套用上述叉积公式,会得到:

可以看到,对于2D向量的叉积来说,其x和y分量总是为0,但是对于点与三角形碰撞检测算法来说,叉积后的z分量x0y1-y0x1才是最关键的,先把z分量的计算作为vec2的一个静态方法。

 /**
   * 计算三角形两条边向量的叉乘
   * @param v0 
   * @param v1 
   * @param v2 
   * @returns 
   */
  public static sign(v0: vec2, v1: vec2, v2: vec2): number {
    // v2->v0边向量
    let e1: vec2 = vec2.difference(v0, v2);
    // v2->v1边向量
    let e2: vec2 = vec2.difference(v1, v2);
    return vec2.crossProduct(e1, e2);
  }
  /**
   * 判断鼠标是否在三角形内部
   * @param pt
   * @param v0
   * @param v1
   * @param v2
   * @returns
   */
  public static isPointInTriangle(pt: vec2, v0: vec2, v1: vec2, v2: vec2) {
    // 计算三角形的三个定点与鼠标形成的三个子三角形的边向量的叉乘
    let b1: boolean = Math2D.sign(v0, v1, pt) < 0.0;
    let b2: boolean = Math2D.sign(v1, v2, pt) < 0.0;
    let b3: boolean = Math2D.sign(v2, v0, pt) < 0.0;
    // 三个三角形的方向一致,说明点在三角形内部
    // 否则点在三角形外部
    return b1 === b2 && b2 === b3;
  }

通过上面的代码,结合上图6.11会发现,实际上并不关心子三角形两条边向量叉积的数值大小,更关心的是叉积的正负性,只要三个子三角形的两条边向量的叉积的正负性都一致的话,表示三个子三角形的顶点排列顺序一致,那么该点P肯定在[v0 , v1 , v2 ]形成的三角形的内部,否则肯定在外部。

3.3.5点与任意凸多边形的碰撞检测

如图6.12所示,可以将任意的凸多边形很方便地分解成三角形,然后依次调用上一节实现的点与三角形碰撞检测算法,这样就能获得点与任意凸多边形碰撞检测的算法。

/**
   * 判断坐标是否在凸多边形内部
   * @param pt
   * @param points
   * @returns
   */
  public static isPointInPolygon(pt: vec2, points: vec2[]): boolean {
    if (points.length < 3) {
      return false;
    }
    // 以point[0]为共享点,遍历多边形点集,构成三角形,判断点是否在三角形内部,
    // 一旦点与某个三角形发生碰撞,就返回true
    for (let i: number = 2; i < points.length; i++) {
      if (Math2D.isPointInTriangle(pt, points[0], points[i - 1], points[i])) {
        return true;
      }
    }
    return false;
  }

3.3.5.1凸多边形判断

参考图6.13会发现,左侧的凸多边形(六边形)顶点形成的6个子三角形分别是[ v0, v1 , v2 ]、[ v1 , v2 , v3 ]、[ v2 , v3 , v4 ]、[ v3 , v4 , v5 ]、[ v4 , v5 , v0 ]和[v5 , v0 , v1 ],这6个子三角形顶点的顺序都是顺时针排列的。

而观察上图右侧的凹多边形,由5个顶点组成,形成的5个子三角形分别是[ v0 , v1, v2 ]、[ v1 , v2 , v3 ]、[ v2 , v3 , v4 ]、[ v3 , v4 , v0 ]和[ v4 , v0 , v1 ],会发现最后一个三角形[ v4 , v0 , v1]的顶点顺序是逆时针排列,而其他的三角形都是顺时针排列。

如何判断一个三角形的顶点顺序,已经在上一节中了解过了,那么直接来看一下判断凸多边形的算法。

/**
   * 判断是否凸多边形
   * @param points 
   * @returns 
   */
  public static isConvex(points: vec2[]): boolean {
    // 算出第一个三角形的定点顺序
    let sign: boolean = Math2D.sign(points[0], points[1], points[2]) < 0;
    let j: number, k: number;
    for (let i: number = 1; i < points.length; i++) {
      j = (i + 1) % points.length;
      k = (i + 2) % points.length;
      // 如果当前多边形的顶点顺序和第一个多边形的顶点顺序不一致,则说明是凹边形
      if (sign !== Math2D.sign(points[i], points[j], points[k]) < 0) {
        return false;
      }
    }
    // 凸多边形
    return true;
  }

4.总结

向量是具有大小和方向的空间变量。而以前常用的数值都是标量,其仅有大小,而没有方向。在向量一节中,知道了如何计算向量的大小、向量的方向、向量的加减法、负向量、向量的缩放,以及向量的点乘。同时更加详细地解释了向量的上述这些操作相对应的几何含义,这是本文的关键点。只有深刻地理解向量的几何含义,才能灵活地应用向量来解决问题。

核心关注点与基本几何形体之间的碰撞检测算法,涉及点与线段、点与圆、点与矩形、点与椭圆、点与三角形,以及点与凸多边形之间的碰撞检测,并且讲解了多边形的三角形化,以及如何判断凸多边形的算法。

Tags:

最近发表
标签列表