基于Canvas或WebGL实现图像、文字的不规则变形

1,571 阅读39分钟

前段时间负责的模块需要引入类似稿定设计编辑器中文字变形的功能,刚接到这个需求时有点懵,因为里面有相当一部分变形效果属于不规则的那种,之前完全没有做过类似的需求,只好一顿瞎搜,终于还是找到了一位大佬分享的效果比较类似的方案——www.cnblogs.com/axes/p/4646… ,这篇文章给了我很大的启发和帮助,经过一段时间的摸索和研究,终于实现想要的效果(然后这个需求搁置了,暂时不做。),也就是Canvas(2D)版;后来又由于可能的需求,入坑了WebGL,在掌握一定基础后(又被告知暂时不需要用到相关技术),想到了利用纹理贴图应该也可以实现这类效果,然后就尝试基于WebGL进行改造,中间遇到不少坑同时也收获了不少,最后实现的效果还是比较理想的,所以想写篇文章分享下心得和技术原理。最后说明一下:本文是以稿定设计中的文字弯曲变形为例展开整个实现、优化的相关原理细节(demo地址)。文章中Canvas和WebGL的相关知识不会展开讲解,默认读者是掌握相关基础的。另外,想向准备学习WebGL的朋友推荐下《WebGL编程指南》,这本书还是比较通俗易懂的,网上有一个pdf版本,里面还包含源码。本文中用到的WebGL的相关知识只需要掌握这本书前六章甚至前五章即可。

1. 核心思路

实现图像/文字不规则变形的核心思路就是将绘制的图像(的矩形区域)拆分为多个三角形区域,当基于不同的变形规则对每个三角形区域应用不用的变换(仿射变换)后,整体就呈现出相应的变形效果了,而三角形越多,变形效果就越好。这样解释可能有的朋友会有点懵逼,如果是接触过OpenGL/Direct Graphics开发或3D建模的朋友应该很容易理解——在计算机三维世界中再复杂的2D图形或3D模型都是由三角形构成的,那么,为什么是三角形呢?关于这个问题,根据查阅资料加个人理解总结出两点原因:

  1. 确定一个面的最少顶点数就是三个,所以用三角形表示一个平面的成本最低。
  2. 任意两个三角形之间的变化都可以用一个变换矩阵来表示,换而言之,三角形之间不存在不规则变形。

上面两点中,第二点很关键,因为超过三条边后,比如四边形,就会出现不规则变形,所谓的不规则变形就是无法通过仿射变换实现的变化效果,即无法通过一个变换矩阵来描述。比如:将图像由原始的矩形变形到等腰梯形就是一个不规则变形了:

可以看出,上面的梯形的变形效果可以由左上、右下两个三角形的变换来组成,但是对角线上的过渡就比较生硬了;如果将这里的矩形和梯形拆分为更多的三角形,那么变形效果就会变得更细腻:

总之,通过将图像拆分为多个三角形,就能把图像上复杂的变形效果分解为多个不同的仿射变换,从而降低了问题的复杂程度,也是用到了分治法的思想。

2. Canvas版实现

弯曲变形的效果如下所示,具体的功能可以打开稿定编辑器体验。

稿定-弯曲变形 从上图中可以看出,弯曲变形其实就是将图像/文字(区域)的形状由原本的矩形变成一个半扇形。在实际绘制中可以用一个个等腰梯形(每个可以分为两个三角形)拼出一个半扇形,因为当图像原始矩形区域按水平和垂直分为多段并拆分为多个矩形时,图像进行弯曲变形后,这些矩形也变成半扇形了,但将其四个角点连接就变成等腰梯形了。分段数越多,这些小的半扇形和梯形就越相似,而整体就越接近一个标准的半扇形了。

分段数为5*5时: 分段数为20*10时:

可以看得出所有的顶点都位于从上到下的多条圆弧上,所以,我们可以先求出左右两条边上的顶点的坐标,再利用每一对(y坐标相同)顶点求出对应圆弧上的顶点的坐标,最终得到所有的顶点(的坐标): 弯曲-所有顶点

拿到这些顶点坐标数据后,就可以代入线性方程组求出变换矩阵(详情参考下面2.3节)了。

2.1 求左右两边上的顶点坐标

为了方便,我实现的版本中左右两边在弯曲(圆弧)角度变化时有一个顶点是固定的(稿定的弯曲变形并不是这样的,没太看懂它这边的变化规律)——向上弯曲时下方的顶点固定,向下弯曲时相反,此时原始边绕上/下顶点旋转后的边就是变形后的边,然后根据分段数量求边上的各个顶点坐标。

curve-left-line

求两边上顶点的实现代码主要部分
/**
 * 求弯曲后半扇型的两条边中一条上的顶点
 * @param p1 矩形上方某顶点
 * @param p2 顶点p1下方的顶点
 * @param angle 弯曲角度
 * @param stepCount 分段数量
 * @param yDir +y轴方向——1表示向下,-1则相反
 * @param widthHeightRatio 画布的宽高比
 * @returns 该边弯曲(旋转)后的顶点数组
 */
function computeCurveEndPoints(
  p1: Point2D,
  p2: Point2D,
  angle: number,
  stepCount: number,
  yDir: CoordDirection,
  widthHeightRatio: number
) {
  // 是否反向弯曲
  const isOpposite = sign(angle) === -1;
  // 计算弯曲后的向量
  const { x: vectorX, y: vectorY } = computeRotatedVector(
    p1,
    p2,
    angle,
    yDir,
    widthHeightRatio
  );
  // 变化的步长向量的x、y
  const stepX = vectorX / stepCount,
    stepY = vectorY / stepCount;
  // 计算边上顶点时的起点,是变形时固定(角度符号未变化时)的点,也是旋转围绕的点
  const startPoint = isOpposite ? p1 : p2;
  const endPoints: Point2D[] = [];
  // 计算出所有的顶点
  for (let i = 0; i <= stepCount; ++i) {
    endPoints.push({
      x: startPoint.x + i * stepX,
      y: startPoint.y + i * stepY,
    });
  }
  // 顶点的顺序是相反需要倒序,如果是反向弯曲则不需要倒序
  return isOpposite ? endPoints : endPoints.reverse();
}

/**
 * 计算点p1和点p2组成的向量旋转后的新向量
 * @param p1 第一个点
 * @param p2 第二个点
 * @param angle 旋转角度
 * @param yDir +y轴方向——1表示向下,-1则相反
 * @param widthHeightRatio 宽高比
 * @returns 旋转后的向量
 */
function computeRotatedVector(
  p1: Point2D,
  p2: Point2D,
  angle: number,
  yDir: CoordDirection,
  widthHeightRatio: number
): Point2D {
  const { x: x1, y: y1 } = p1;
  const { x: x2, y: y2 } = p2;
  // 是否反向弯曲
  const isOpposite = sign(angle) === -1;
  // 边旋转的角度
  const rotateRad = (angle / 2) * yDir;
  // 向量p1p2/p2p1的x分量
  const vectorX = isOpposite ? x2 - x1 : x1 - x2;
  // 向量p1p2/p2p1的y分量
  const vectorY = isOpposite ? y2 - y1 : y1 - y2;
  // 旋转后的向量x分量
  const x =
    cos(rotateRad) * vectorX - (sin(rotateRad) * vectorY) / widthHeightRatio;
  // 旋转后的向量y分量
  const y =
    sin(rotateRad) * vectorX * widthHeightRatio + cos(rotateRad) * vectorY;
  return { x, y };
}

2.2 求圆弧上的顶点

上一步求出两边上所有顶点后,就可以通过每对顶点(端点)坐标及弯曲(圆弧)角度求出圆弧上所有顶点。大致流程如下:

  1. 已知两端点A和B的坐标,可求出AB长度,设AB中点为M,圆心为O,那么OAB就是一个等腰三角形,而OAM和OBM是两个对称全等的直角三角形,OA/OB为斜边;设弯曲角度为α,半径为R,那么显然有:
AM=(A.xB.x)2+(A.yB.y)2/2OM=AM/tan(α/2)R=OM2+AM2\begin{aligned} & AM = \sqrt{(A.x-B.x)^{2} + (A.y-B.y)^{2}}/2 \\ & OM = AM/ \tan(\alpha /2) \\ & R = \sqrt{OM^{2} + AM^{2}} \end{aligned}
  1. 根据分段数量确定圆弧AB上均匀分布的各个顶点与圆心连接的线段和OM之间夹角,配合半径即可确定顶点的位置——比如存在某顶点P,设OP与OM的夹角为β,弯曲角度符号位(值为+1/-1,+1表示相对于OM是逆时针的角度,-1则相反)为s,+y轴符号位为d(向下为+1,因为画布坐标系+y轴是向下的;向上为-1),则有:
P.x=O.x+Rsin(sβ)P.y=O.y+Rcos(sβ)d\begin{aligned} & P.x = O.x + R\sin(-s\beta) \\ & P.y = O.y + R\cos(-s\beta)d \end{aligned}

上面涉及的几何图形如下所示:

圆弧-顶点

求圆弧上所有顶点的实现代码的主要部分
/**
 * 计算圆弧上所有顶点数据,将每个顶点数据传入回调中进行处理
 * @param p1 圆弧的左端点
 * @param p2 圆弧的右端点
 * @param angle 弯曲角度(圆弧角度)
 * @param stepCount 分段数量
 * @param yDir +y轴方向——1表示向下,-1则相反
 * @param widthHeightRatio 画布宽高比
 * @param curveDir 圆弧弯曲的方向,1表示向上弯曲,-1则相反
 * @param offsetRad 左右端点组成的向量相对于+x轴的角度取反后的值
 * @param realStep 圆心到圆弧上两个顶点的向量的夹角
 * @param from 圆心到左端点的向量相对于+y轴的角度
 * @param yIndex 当前顶点的y方向的索引
 * @param pointCallback 使用顶点数据的回调
 */
function handlePointsOnArc(
  p1: Point2D,
  p2: Point2D,
  angle: number,
  stepCount: number,
  yDir: CoordDirection,
  widthHeightRatio: number,
  curveDir: number,
  offsetRad: number,
  realStep: number,
  from: number,
  yIndex: number,
  pointCallback: PointCallback
) {
  // 计算圆弧相关参数
  const {
    center: { x: centerX, y: centerY },
    radius,
  } = computeArcParams(p1, p2, angle, yDir, widthHeightRatio, offsetRad);

  // 求圆弧上的顶点,并传入回调中执行
  for (let i = from, count = 0; count <= stepCount; i += realStep, ++count) {
    pointCallback(
      centerX + radius * sin(i),
      // 最后的y值需要乘以宽高比(之前除以宽高比以保证不受画布宽高不相等的影响,这里要还原)
      (centerY + radius * cos(i) * curveDir) * widthHeightRatio,
      count,
      yIndex
    );
  }
}

/**
 * 计算圆弧相关参数
 * @param p1 圆弧的一个端点
 * @param p2 圆弧的另一个端点
 * @param angle 圆弧角度
 * @param yDir +y轴方向——1表示向下,-1则相反
 * @param widthHeightRatio 宽高比
 * @param offsetRad 两端点连线相对于水平方向的偏移角度
 * @returns 圆弧相关参数
 */
function computeArcParams(
  p1: Point2D,
  p2: Point2D,
  angle: number,
  yDir: CoordDirection,
  widthHeightRatio: number,
  offsetRad: number
): ArcParams {
  const pa = { ...p1 };
  const pb = { ...p2 };
  // 若传入的pa、pb已经转换为NDC下的坐标,则需要除以宽高比保证计算的角度等信息不会存在误差(画布宽高非1:1则转化到NDC下的坐标相当于被水平/垂直缩放过)
  pa.y /= widthHeightRatio;
  pb.y /= widthHeightRatio;
  const { x: x1, y: y1 } = pa;
  const { x: x2, y: y2 } = pb;
  // 两端点和圆心构成的三角形中圆心点的对边的一半
  const sinLen = hypot(x2 - x1, y2 - y1) / 2;
  // 两端点和圆心构成的三角形中圆心对边上的中线, 该中线正好将三角形分为两个全等三角形
  const cosLen = sinLen / tan(angle / 2);
  // 圆弧半径
  const radius = hypot(sinLen, cosLen);
  return {
    center: {
      x: (x2 + x1) / 2 + cosLen * sin(offsetRad),
      y: (y2 + y1) / 2 + cosLen * cos(offsetRad) * yDir,
    },
    radius,
  };
}

2.3 基于顶点坐标计算变换矩阵

基于上面两步,整合在一起即可求出所有变形的顶点坐标,这些顶点是构成半扇形的所有梯形的顶点,也是所有三角形的顶点,根据变形前后的三角形的三个顶点可以得到两组三元一次方程组,解这两个方程组即可得到2D变换矩阵的所有元素。具体的推导过程如下所示:

  1. 设变形前的三角形的一个顶点坐标为(x1, y1),变形后,即应用变换矩阵后,该顶点变为(X1, Y1):
(acebdf001)(x1y11)=(ax1+cy1+ebx1+dy1+f1)=(X1Y11)\begin{pmatrix}a&c&e\\b&d&f\\0&0&1\\ \end{pmatrix} \ast \begin{pmatrix}x_1\\y_1\\1\\ \end{pmatrix} = \begin{pmatrix}ax_1+cy_1+e\\bx_1+dy_1+f\\1\\ \end{pmatrix} = \begin{pmatrix}X_1\\Y_1\\1\\ \end{pmatrix}

用方程组来表示上面的关系即为:

{ax1+cy1+e=X1bx1+dy1+f=Y1\left\{ \begin{array}{c} ax_1+cy_1+e=X_1 \\ bx_1+dy_1+f=Y_1 \\ \end{array} \right.
  1. 根据三角形变形前的三个顶点和变形后的三个顶点以及上面推导的关系可得到下面两个三元一次方程组:
{ax1+cy1+e=X1ax2+cy2+e=X2ax3+cy3+e=X3{bx1+dy1+f=Y1bx2+dy2+f=Y2bx3+dy3+f=Y3\left\{ \begin{array}{c} ax_1+cy_1+e=X_1 \\ ax_2+cy_2+e=X_2 \\ ax_3+cy_3+e=X_3 \\ \end{array} \right.\\ \left\{ \begin{array}{c} bx_1+dy_1+f=Y_1 \\ bx_2+dy_2+f=Y_2 \\ bx_3+dy_3+f=Y_3 \\ \end{array} \right.
  1. 代入六个顶点坐标数据,并求解方程组,就可以得到a,b,c,d,e,f六个元素的值了。常用的解线性方程组的方法有消元法和逆矩阵求解,逆矩阵求解的方式要复杂一点,并且计算量(通过比较常用的伴随矩阵求逆矩阵时)较大,所以这里通过消元法来求解。消元思路就是从n元线性方程组中取出n-1对不同的方程组合,两两消掉一项(第二项),得到一个n-1元线性方程组,直到消到一元为止;一元方程可以直接代入求解,然后代入之前得到二元方程组中一个二元线性方程,又解出第二个未知数,以此类推,最终就能解出所有未知数了。下面是三元一次方程组的消元过程:
{a1x+b1y+c1z=d1a2x+b2y+c2z=d2a3x+b3y+c3z=d3\left\{ \begin{array}{c} a_1x+b_1y+c_1z=d_1 \\ a_2x+b_2y+c_2z=d_2 \\ a_3x+b_3y+c_3z=d_3 \\ \end{array} \right.

将上面第一和第二个方程消掉第二个系数;第二和第三个方程消掉第二个系数,得到如下的二元一次方程组:

{(a1b1a2/b2)x+(c1b1c2/b2)z=d1b1d2/b2(a2b2a3/b3)x+(c2b2c3/b3)z=d2b2d3/b3\left\{ \begin{array}{c} (a_1-b_1a_2/b_2)x+(c_1-b_1c_2/b_2)z=d_1-b_1d_2/b_2 \\ (a_2-b_2a_3/b_3)x+(c_2-b_2c_3/b_3)z=d_2-b_2d_3/b_3 \\ \end{array} \right.

继续消掉第二个系数,即可得到一个一元一次方程:

((a1b1a2/b2)(c1b1c2/b2)(a2b2a3/b3)/(c2b2c3/b3))x=(d1b1d2/b2)(d2b2d3/b3)(c1b1c2/b2)/(c2b2c3/b3)((a_1-b_1a_2/b_2)-(c_1-b_1c_2/b_2)(a_2-b_2a_3/b_3)/(c_2-b_2c_3/b_3))x = (d_1-b_1d_2/b_2)-(d_2-b_2d_3/b_3)(c_1-b_1c_2/b_2)/(c_2-b_2c_3/b_3)

然后就解出x了,再代入上面的一个二元一次方程中即可解出z,再代入x和z到三元一次方程中,即可解出y。

求解三元线性方程组及求变换矩阵的代码
/**
 * 根据变化前后的点坐标,计算2D变换矩阵
 * @param p1     变化前坐标1
 * @param cp1    变化后坐标1
 * @param p2     变化前坐标2
 * @param cp2    变化后坐标2
 * @param p3     变化前坐标3
 * @param cp3    变化后坐标3
 * @returns 2d模型变换矩阵
 */
export function computeTransformMatrix(
  p1: Point2D,
  cp1: Point2D,
  p2: Point2D,
  cp2: Point2D,
  p3: Point2D,
  cp3: Point2D
): TransformMatrix2D {
  //传入x值解第一个方程 即  X = ax + cy + e 求ace
  //传入的四个参数,对应三元一次方程:ax+by+cz=d的四个参数:a、b、c、d,跟矩阵方程对比c为1
  const equation1 = [p1.x, p1.y, 1, cp1.x];
  const equation2 = [p2.x, p2.y, 1, cp2.x];
  const equation3 = [p3.x, p3.y, 1, cp3.x];

  //获得a、c、e
  const [a, c, e] = solveEquation3(equation1, equation2, equation3);

  //传入y值解第二个方程 即  Y = bx + dy + f 求 bdf
  equation1[3] = cp1.y;
  equation2[3] = cp2.y;
  equation3[3] = cp3.y;

  //获得b、d、f
  const [b, d, f] = solveEquation3(equation1, equation2, equation3);

  return [a, b, c, d, e, f];
}

/**
 * 求解三元一次方程组,每个数组相当于增广矩阵的每一行元素
 * @param equation1 第一个三元方程系数及已知值构成的数组
 * @param equation2 第二个三元方程系数及已知值构成的数组
 * @param equation3 第三个三元方程系数及已知值构成的数组
 * @returns 解数组
 */
export function solveEquation3(
  equation1: number[],
  equation2: number[],
  equation3: number[]
) {
  const [a1, b1, c1, d1] = equation1;

  const eq1 = elimination(equation1, equation2);
  const eq2 = elimination(equation2, equation3);
  const [a2, c2, d2] = eq2;

  const [a3, d3] = elimination(eq1, eq2);

  const x = d3 / a3;
  const z = (d2 - a2 * x) / c2;
  const y = (d1 - a1 * x - c1 * z) / b1;

  return [x, y, z];
}

/**
 * 消元并返回新的已知量数组, 只有一元以上才需要消元
 * @param eq1 需要消元的方程1
 * @param eq2 需要消元的方程2
 * @returns
 */
function elimination(eq1: number[], eq2: number[]) {
  // 消元时乘以的系数
  const e = eq1[1] / eq2[1];
  // 保存消元后得到的新已知量的数组
  const ret: number[] = [];
  for (let i = 0; i < eq1.length; ++i) {
    if (i !== 1) {
      ret.push(eq1[i] - eq2[i] * e);
    }
  }
  return ret;
}

在我的demo项目中还实现了求解n元线性方程组的函数,有需要或有兴趣的可以参考一下

2.4 实现图像的弯曲变形

结合前面的代码,整合得到完整的计算和绘制的逻辑:

  1. 利用图像矩形区域四个顶点、弯曲角度、水平/垂直分段数量等参数计算变形前后所有(四边形/三角形)的顶点坐标。
  2. 遍历所有顶点,每遍历到一个顶点,都进行如下操作:
    1. 如果该顶点是图像拆分的某个四边形区域的左上角顶点,那么就进入后续流程,否则退出。
    2. 取出当前四边形区域变形前后的八个顶点坐标,依次处理左上三角形和右下三角形部分。
    3. 通过左上三角形变形前后六个顶点计算出变换矩阵,裁剪出变形后的左上三角形区域,应用左上三角形的变换矩阵,绘制图像。
    4. 通过右下上三角形变形前后六个顶点计算出变换矩阵,裁剪出变形后的右下三角形区域,应用右下三角形的变换矩阵,绘制图像。
绘制弯曲变形图像的实现代码的剩余部分
/**
 * 绘制弯曲后的图像
 * @param ctx canvas 2d绘制上下文
 * @param pa 图像矩形区域的左上顶点
 * @param pb 图像矩形区域的右上顶点
 * @param pc 图像矩形区域的右下顶点
 * @param pd 图像矩形区域的左下顶点
 * @param angle 弯曲的角度
 * @param xCount 水平方向分段数量
 * @param img 原始图像资源
 * @param hasDot 是否绘制顶点
 * @param hasLine 是否绘制划分后的三角形的边
 * @param hasPic 是否绘制图像
 * @param yCount 垂直方向分段数量
 */
export function drawCurveImage_Deprecated(
  ctx: CanvasRenderingContext2D,
  pa: Point2D,
  pb: Point2D,
  pc: Point2D,
  pd: Point2D,
  angle: number,
  xCount: number,
  img: HTMLImageElement | ImageBitmap,
  hasDot = false,
  hasLine = false,
  hasPic = true,
  yCount = xCount
) {
  // console.time('render time');
  // 弯曲前的图像的所有顶点
  const originalPoints = computeOriginalPoints(pa, pb, pc, pd, xCount, yCount);
  // 弯曲后的图像的所有顶点
  const curvePoints = computeCurvePoints(pa, pb, pc, pd, angle, xCount, yCount);

  ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
  curvePoints.forEach((p, i) => {
    if (hasDot) {
      ctx.fillStyle = 'red';
      ctx.fillRect(p.x - 2, p.y - 2, 4, 4);
    }
    // 判断当前顶点和其右侧第一个顶点、下方第一个顶点、右下方第一个顶点构成图像一个四边形(梯形)区域
    if (!canDrawClip(curvePoints, i, xCount)) {
      return;
    }
    //获取弯曲后该四边形区域的四个顶点坐标
    const p1 = curvePoints[i];
    const p2 = curvePoints[i + 1];
    const p3 = curvePoints[i + xCount + 2];
    const p4 = curvePoints[i + xCount + 1];

    //获取弯曲前该矩形区域的四个顶点坐标
    const op1 = originalPoints[i];
    const op2 = originalPoints[i + 1];
    const op3 = originalPoints[i + xCount + 2];
    const op4 = originalPoints[i + xCount + 1];

    // 绘制变形后四边形区域左上三角形部分
    const upTransform = computeTransformMatrix(op1, p1, op2, p2, op4, p4);
    renderClipImage(
      ctx,
      upTransform,
      [p1, p2, p4],
      originalPoints[0],
      img,
      hasLine,
      hasPic
    );
    // 绘制变形后四边形区域右下三角形部分
    const downTransform = computeTransformMatrix(op3, p3, op2, p2, op4, p4);
    renderClipImage(
      ctx,
      downTransform,
      [p3, p2, p4],
      originalPoints[0],
      img,
      hasLine,
      hasPic
    );
  });
  // console.timeEnd('render time');
}

/**
 * 将 abcd 四边形分割成 xCount * yCount 个四边形,获取所有顶点
 * @param pa 矩形的左上顶点
 * @param pb 矩形的右上顶点
 * @param pc 矩形的右下顶点
 * @param pd 矩形的左下顶点
 * @param xCount 水平方向分段数量
 * @param yCount 垂直方向分段数量
 * @returns 返回所有顶点的数组
 */
export function computeOriginalPoints(
  pa: Point2D,
  pb: Point2D,
  pc: Point2D,
  pd: Point2D,
  xCount: number,
  yCount = xCount
) {
  // ad 向量方向 n 等分
  const ad_x = (pd.x - pa.x) / yCount;
  const ad_y = (pd.y - pa.y) / yCount;
  // bc 向量方向 n 等分
  const bc_x = (pc.x - pb.x) / yCount;
  const bc_y = (pc.y - pb.y) / yCount;

  const points = [];

  //左边点递增,右边点递增,获取每一次递增后的新的向量,继续 n 等分,从而获取所有点坐标
  for (let i = 0; i <= yCount; ++i) {
    //获得 ad 向量 n 等分后的坐标
    const x1 = pa.x + ad_x * i;
    const y1 = pa.y + ad_y * i;
    //获得 bc 向量 n 等分后的坐标
    const x2 = pb.x + bc_x * i;
    const y2 = pb.y + bc_y * i;

    for (let j = 0; j <= xCount; ++j) {
      // ab 向量为:[x2 - x1 , y2 - y1],所以 n 等分后的增量为除于 n
      const ab_x = (x2 - x1) / xCount;
      const ab_y = (y2 - y1) / xCount;

      points.push({
        x: x1 + ab_x * j,
        y: y1 + ab_y * j,
      });
    }
  }

  return points;
}

/**
 * 判断是否能够计算并绘制裁剪区域
 * @param points 顶点数组
 * @param i 顶点索引
 * @param stepCount 分段数量
 * @returns 只有非右/下边缘的点才能进行计算并绘制
 */
export function canDrawClip(points: Point2D[], i: number, stepCount: number) {
  return points[i + stepCount + 2] && i % (stepCount + 1) < stepCount;
}

/**
 * 绘制弯曲后的图像的某个裁剪区域
 * @param ctx canvas 2d绘制上下文
 * @param matrix 2d模型变换矩阵
 * @param points 路径顶点数组
 * @param startPoint 路径起始顶点
 * @param img 原始图像源
 * @param hasLine 是否绘制划分后的三角形的边
 * @param hasPic 是否绘制图像
 */
function renderClipImage(
  ctx: CanvasRenderingContext2D,
  matrix: TransformMatrix2D,
  points: Point2D[],
  startPoint: Point2D,
  img: HTMLImageElement | ImageBitmap,
  hasLine: boolean,
  hasPic: boolean
) {
  ctx.save();
  //根据变换后的坐标创建剪切区域
  ctx.beginPath();
  ctx.moveTo(points[0].x, points[0].y);
  for (const p of points) {
    ctx.lineTo(p.x, p.y);
  }
  ctx.closePath();
  // 在这里绘制三角形边框
  if (hasLine) {
    ctx.lineWidth = 1;
    ctx.strokeStyle = 'indianred';
    ctx.stroke();
  }
  // 裁剪
  ctx.clip();

  if (hasPic) {
    //变形
    ctx.transform(...matrix);
    //绘制图像
    ctx.drawImage(img, startPoint.x, startPoint.y, img.width, img.height);
  }

  ctx.restore();
}

/**
 * 求矩形弯曲后的图像区域的所有顶点
 * @param pa 原始图像矩形区域左上顶点坐标
 * @param pb 原始图像矩形区域右上顶点坐标
 * @param pc 原始图像矩形区域右下顶点坐标
 * @param pd 原始图像矩形区域左下顶点坐标
 * @param angle 弯曲角度
 * @param xCount 水平分段数量
 * @param yCount 垂直分段数量
 * @param yDir +y轴方向——1表示向下,-1则相反
 * @param widthHeightRatio 画布宽高比
 * @returns 弯曲后图像上的顶点数组
 */
export function computeCurvePoints(
  pa: Point2D,
  pb: Point2D,
  pc: Point2D,
  pd: Point2D,
  angle: number,
  xCount = 10,
  yCount = xCount,
  yDir: CoordDirection = 1,
  widthHeightRatio = 1
): Point2D[] {
  if (angle > 180 || angle < -180) {
    return [];
  }
  // 弯曲角度为0则返回原图像矩形的所有顶点数组
  if (angle === 0) {
    return computeOriginalPoints(pa, pb, pc, pd, xCount, yCount);
  }
  const curvePoints: Point2D[] = [];
  // 计算并更新弯曲的图像的顶点数组
  handleCurvePoints(
    pa,
    pb,
    pc,
    pd,
    angle,
    (x: number, y: number) => {
      curvePoints.push({ x, y });
    },
    xCount,
    yCount,
    yDir,
    widthHeightRatio
  );
  return curvePoints;
}

/**
 * 计算处弯曲后图像上的顶点并利用顶点数据进行操作
 * @param pa 原始图像矩形区域左上顶点坐标
 * @param pb 原始图像矩形区域右上顶点坐标
 * @param pc 原始图像矩形区域右下顶点坐标
 * @param pd 原始图像矩形区域左下顶点坐标
 * @param angle 图像弯曲的角度
 * @param pointCallback 使用顶点数据的回调
 * @param xCount 水平方向的分段数量
 * @param yCount 垂直方向的分段数量
 * @param yDir +y轴方向——1表示向下,-1则相反
 * @param widthHeightRatio 画布宽高比
 */
export function handleCurvePoints(
  pa: Point2D,
  pb: Point2D,
  pc: Point2D,
  pd: Point2D,
  angle: number,
  pointCallback: PointCallback,
  xCount = 10,
  yCount = xCount,
  yDir: CoordDirection = 1,
  widthHeightRatio = 1
): void {
  // 角度转化为弧度
  angle = angleToRadian(angle);
  // 计算角度相关的参数
  const { curveDir, offsetRad, realStep, from } = computeAngleParams(
    pa,
    pb,
    angle,
    xCount,
    yDir,
    widthHeightRatio
  );
  // 扇形(弯曲后的形状为扇形)左边上的所有顶点
  const leftEndPoints = computeCurveEndPoints(
    pa,
    pd,
    angle,
    yCount,
    -yDir as CoordDirection,
    widthHeightRatio
  );
  // 扇形右边上的所有顶点
  const rightEndPoints = computeCurveEndPoints(
    pb,
    pc,
    angle,
    yCount,
    yDir as CoordDirection,
    widthHeightRatio
  );
  for (let i = 0; i < leftEndPoints.length; ++i) {
    // 计算每对顶点对应的圆弧上的顶点,并传入到回调中进行处理
    handlePointsOnArc(
      leftEndPoints[i],
      rightEndPoints[i],
      angle,
      xCount,
      yDir,
      widthHeightRatio,
      curveDir,
      offsetRad,
      realStep,
      from,
      i,
      pointCallback
    );
  }
}

/**
 * 计算求圆弧上顶点时需要的角度相关的参数
 * @param p1 圆弧左/上端点
 * @param p2 圆弧右/下端点
 * @param angle 弯曲的角度
 * @param stepCount 分段数量
 * @param yDir +y轴方向——1表示向下,-1则相反
 * @param widthHeightRatio 画布宽高比
 * @returns 返回角度相关的参数
 */
function computeAngleParams(
  p1: Point2D,
  p2: Point2D,
  angle: number,
  stepCount: number,
  yDir: CoordDirection,
  widthHeightRatio: number
) {
  const pa = { ...p1 };
  const pb = { ...p2 };
  // 获取等比例下y方向分量——主要是应对webgl下的场景,webgl下需要将传入的顶点的x/y值映射到-1到1的范围(相对于画布的宽高)
  pa.y /= widthHeightRatio;
  pb.y /= widthHeightRatio;
  const { x: x1, y: y1 } = pa;
  const { x: x2, y: y2 } = pb;
  // 圆弧弯曲的方向(中心线的方向),1表示向上弯曲,-1则相反
  const curveDir = -sign(angle) * yDir;
  // p1到p2的向量的旋转角度取反后的值——为了计算圆心位置,需要利用该角度取反后的值计算出准确的偏移量
  const offsetRad = atan2((y1 - y2) * yDir, x2 - x1) % PI;
  // 设置正确的旋转角度
  angle *= yDir;
  // 旋转角度分量
  const realStep = (-curveDir * angle) / stepCount;
  // 向量op1的旋转角度,旋转起始角度
  const from = (angle / 2 + offsetRad) * curveDir;
  return { curveDir, offsetRad, realStep, from };
}

2.4 解决线框问题

上面的代码中,绘制弯曲变形图像的最上层的函数我在名称后面加了个Deprecated后缀,是因为这个函数还存在一个问题——我开头提到的那篇博客中demo代码的绘制效果存在一个问题,而这个函数完全复刻了那部分逻辑,所以也存在相同的问题,那就是变形后的图像中每个三角形区域存在线框(颜色和画布背景色一致): 弯曲变形-存在线框

这个问题在我找到那篇博客的论坛中也有人求助过,不过没人能解决或者是没人感兴趣吧,当时我还以为很难处理,想放弃这种方案,不过当时找不到其它有用的方案所以只能试着去解决了。后来大概的原因的找到了,和CanvasRenderingContext2D.prototype.beginPath方法的调用有关,调用beginPath后绘制的新的裁剪区域和之前裁剪区域重叠的部分如果只有一个像素宽,那么这部分像素会被擦除掉,具体原因和beginPath的实现有关,这个要深究就比较难了(Canvas 2D的API底层是基于skia实现的),也不是本文的重点。不过知道了大概原因之后,经过一定摸索后也找到了解决办法——将原始的三角形区域改为多边形,让相邻三角形的边重叠部分宽度超过一像素: 三角形改为多边形

让左上部分三角形变为四边形/五边形或不变,右下部分三角形变为五边形,左上部分三角形是否变化或变化时是变为四边形还是五边形和它位置有关——上面新增的边暂时称为迂回边(这个术语是我编的,若不好理解请见谅),迂回边的长度都为1像素,而对应的顶点即为迂回点,右下三角形的两个迂回点的迂回方向都是朝向A点的,即DD2为向量DA的单位向量,BB2为BA单位向量;而左上三角形的迂回点取决于它左侧/上侧/左上侧是否存在矩形区域,具体而言,左侧存在矩形区域时,DD1存在且为D到左侧矩形的顶点D的向量的单位向量;上侧存在矩形区域时,BB1存在且为B到上侧矩形的顶点B的向量的单位向量;A1的情况比较特殊,它存在时会替换掉A,而AA1的值有四种情况——只有左侧存在矩形时,它为A到左侧矩形的顶点A的单位向量;只有上测存在矩形时也是类似的情况;而左侧上侧都存在时则AA1为A到左上侧矩形的顶点A的单位向量;左侧和上侧都不存在矩形时则AA1为0向量,即A1、B1、D1不存在,左上三角形无变化。

生成包含迂回边多边形路径的实现代码在这里
/** 计算矩形裁剪区域的的顶点路径
 * @param pa 矩形的左上顶点
 * @param pb 矩形的右上顶点
 * @param pc 矩形的右下顶点
 * @param pd 矩形的左下顶点
 * @param i 矩形左上角顶点的索引
 * @param stepCount 分段数量
 * @param curvePoints 变形后的所有矩形的顶点数组
 * @returns 该矩形左上和右下部分的多边形的顶点数组(裁剪路径数组)
 */
export function computeClipShapePaths(
  pa: Point2D,
  pb: Point2D,
  pc: Point2D,
  pd: Point2D,
  i: number,
  stepCount: number,
  curvePoints: Point2D[]
): ShapePaths {
  // 左上三角形的三个(可能的)迂回点
  let pbTo = null;
  let pdTo = null;
  let paTo = null;

  // 右下部分多边形的顶点路径数组
  const downPath = [
    pc,
    pb,
    computeDetourPoint(pb, pa),
    computeDetourPoint(pd, pa),
    pd,
  ];
  const upPath = [pa, pb, pd];
  // 矩形索引大于分段数量即上方(未变形情况下的上方)存在矩形
  if (i > stepCount) {
    pbTo = curvePoints[i - stepCount];
  }
  // 矩形索引对分段数量加1取余若大于0则左侧存在矩形
  if (i % (stepCount + 1) > 0) {
    pdTo = curvePoints[i + stepCount];
  }
  // 顶点B和D对应迂回点都存在时则基于左上侧矩形的顶点A求迂回点
  if (pbTo && pdTo) {
    paTo = curvePoints[i - stepCount - 2];
    upPath.splice(
      0,
      1,
      computeDetourPoint(pd, pdTo),
      computeDetourPoint(pa, paTo),
      computeDetourPoint(pb, pbTo)
    );
  }
  // 只有B的迂回点存在时则基于上侧矩形的顶点A求迂回点
  else if (pbTo) {
    paTo = curvePoints[i - stepCount - 1];
    upPath.splice(
      0,
      1,
      computeDetourPoint(pa, paTo),
      computeDetourPoint(pb, pbTo)
    );
  }
  // 只有D的迂回点存在时则基于左侧矩形的顶点A求迂回点
  else if (pdTo) {
    paTo = curvePoints[i - 1];
    upPath.splice(
      0,
      1,
      computeDetourPoint(pd, pdTo),
      computeDetourPoint(pa, paTo)
    );
  }

  return {
    upPath,
    downPath,
  };
}

/**
 * 计算迂回点
 * @param from 向量起点
 * @param to 向量终点
 * @returns 迂回点坐标
 */
function computeDetourPoint(from: Point2D, to: Point2D): Point2D {
  const { x: fx, y: fy } = from;
  const { x: tx, y: ty } = to;
  const dx = tx - fx;
  const dy = ty - fy;
  const len = hypot(dx, dy);
  return {
    x: fx + dx / len,
    y: fy + dy / len,
  };
}
绘制图像的函数也要修改
/**
 * 绘制弯曲后的图像
 * @param ctx canvas 2d绘制上下文
 * @param pa 图像矩形的左上顶点
 * @param pb 图像矩形的右上顶点
 * @param pc 图像矩形的右下顶点
 * @param pd 图像矩形的左下顶点
 * @param angle 弯曲的角度
 * @param xCount 水平方向分段数量
 * @param img 原始图像资源
 * @param hasDot 是否绘制顶点
 * @param hasLine 是否绘制划分后的三角形的边
 * @param hasPic 是否绘制图像
 * @param yCount 垂直方向分段数量
 */
export function drawCurveImage(
  ctx: CanvasRenderingContext2D,
  pa: Point2D,
  pb: Point2D,
  pc: Point2D,
  pd: Point2D,
  angle: number,
  xCount: number,
  img: HTMLImageElement | ImageBitmap,
  hasDot = false,
  hasLine = false,
  hasPic = true,
  yCount = xCount
) {
  // 弯曲的图像的所有顶点
  const originalPoints = computeOriginalPoints(pa, pb, pc, pd, xCount, yCount);
  // 弯曲后的图像的所有顶点
  const curvePoints = computeCurvePoints(pa, pb, pc, pd, angle, xCount, yCount);

  ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
  // console.time('canvas draw')
  curvePoints.forEach((p, i) => {
    //获取弯曲后的四边形的四个点
    const p1 = curvePoints[i];
    const p2 = curvePoints[i + 1];
    const p3 = curvePoints[i + xCount + 2];
    const p4 = curvePoints[i + xCount + 1];

    //获取初始矩形的四个点
    const op1 = originalPoints[i];
    const op2 = originalPoints[i + 1];
    const op3 = originalPoints[i + xCount + 2];
    const op4 = originalPoints[i + xCount + 1];

    if (canDrawClip(curvePoints, i, xCount)) {
      // 计算绘制路径顶点数组
      const { upPath, downPath } = computeClipShapePaths(
        p1,
        p2,
        p3,
        p4,
        i,
        xCount,
        curvePoints
      );

      //绘制三角形的上半部分
      const upTransform = computeTransformMatrix(op1, p1, op2, p2, op4, p4);
      renderClipImage(
        ctx,
        upTransform,
        upPath,
        originalPoints[0],
        img,
        hasLine,
        hasPic
      );
      //绘制三角形的下半部分
      const downTransform = computeTransformMatrix(op3, p3, op2, p2, op4, p4);
      renderClipImage(
        ctx,
        downTransform,
        downPath,
        originalPoints[0],
        img,
        hasLine,
        hasPic
      );
    }

    if (hasDot) {
      ctx.fillStyle = 'red';
      ctx.fillRect(p.x - 2, p.y - 2, 4, 4);
    }
  });
  // console.timeEnd('canvas draw')
}

增加这一步处理,绘制结果基本就没问题了: 弯曲变形-canvas

2.5 实现文字弯曲变形

实现文字弯曲变形其实就是先将渲染的文字生成一张图像,然后再绘制弯曲后的图像即可。这里将文字转化为图像主要有两个原因:

  1. Canvas中在应用了变换和裁剪的情况绘制文字时性能较差(背后的原因不得而知,可能和Canvas API实现有关),在频繁绘制的情况下会有明显的卡顿。
  2. 后面基于WebGL改造时需要使用图像作为纹理,而且WebGL中也没有绘制文字的api。
文字弯曲变形的相关代码
/**
 * 绘制弯曲的文字
 * @param ctx canvas 2d绘制上下文
 * @param angle 弯曲的角度
 * @param xCount 水平方向分段数量
 * @param yCount 垂直方向分段数量
 * @param textPicture 绘制的文字的图像
 * @param textRect 绘制的位置、尺寸
 */
export function drawCurveText(
  ctx: CanvasRenderingContext2D,
  angle: number,
  xCount: number,
  yCount: number,
  textPicture: ImageBitmap,
  textRect: TextRect
) {
  const { x, y, width, height } = textRect;
  const pa: Point2D = { x, y },
    pb: Point2D = { x: x + width, y },
    pc: Point2D = { x: x + width, y: y + height },
    pd: Point2D = { x, y: y + height };
  drawCurveImage(
    ctx,
    pa,
    pb,
    pc,
    pd,
    angle,
    xCount,
    textPicture,
    false,
    false,
    true,
    yCount
  );
}

/**
 * 求文字绘制的位置(实际位置)、尺寸
 * @param ctx canvas 2d绘制上下文
 * @param text 文字的内容
 * @param x 文字绘制的x坐标
 * @param y 文字绘制的y坐标
 * @returns 文字绘制的位置、尺寸
 */
export function computeTextRect(
  ctx: CanvasRenderingContext2D,
  text: string,
  x = 0,
  y = 0
): TextRect {
  const { width, actualBoundingBoxAscent, actualBoundingBoxDescent } =
    ctx.measureText(text);
  const height = actualBoundingBoxAscent + actualBoundingBoxDescent;
  return {
    x,
    y: y - actualBoundingBoxAscent,
    width,
    height,
  };
}

/**
 * 生成渲染后的文字的图像——因为canvas渲染文字的成本较高(性能较差),且webgl没有渲染文字的api,所以需要这个函数才将文字转化为图像
 * @param ctx canvas 2d绘制上下文
 * @param targetCtx 待拷贝的2d绘制上下文——实际进行绘制的画布的2d上下文。
 * @param text 文字的内容
 * @param rect 文字渲染的位置、尺寸
 * @returns 生成的图像的bitmap的promise对象
 */
export function genTextPicture(
  ctx: CanvasRenderingContext2D,
  targetCtx: CanvasRenderingContext2D,
  text: string,
  rect: TextRect
) {
  const { width, height } = rect;

  ctx.clearRect(0, 0, width, height);
  ctx.canvas.width = width;
  ctx.canvas.height = height;
  ctx.font = targetCtx.font;
  ctx.textBaseline = targetCtx.textBaseline;
  ctx.fillStyle = targetCtx.fillStyle;
  ctx.beginPath();
  ctx.fillText(text, 0, 0);
  return new Promise((resolve) => {
    ctx.canvas.toBlob((blob) => {
      resolve(createImageBitmap(blob as Blob));
    });
  }).catch((err) => console.error(err)) as Promise<ImageBitmap>;
}

实现效果如下:

3. 基于WebGL进行优化改造

上面基于Canvas 2D的实现版本在实现效果上已经基本符合需求了,但还是存在两个比较严重的问题:

  1. 当两个维度的分段数量差异较大时,仍然会出现线框:

  2. 2D版的实现性能较差——首先时间复杂度为O(m*n),这个没法降低;其次时间频度(没具体算过)中系数比较大,即每次循环的计算量较大,包括:两次计算顶点(特别是计算变形后顶点)坐标、计算变换矩阵(每次要解两组三元一次方程组),计算迂回边等;最重要的就是这些计算在js中是没法(或者说很难)通过多线程(worker,伪多线程)去优化。下面是当分段达到50*50后每次(调整弯曲角度过程中)渲染时间:

虽然只有几十毫秒,看起来似乎不算高,但实际上已经会表现出明显的卡顿了(可下载demo项目运行查看效果),而这还只是50*50的情况(2D版实现在分段数量不超过20*20的情况下都还算流畅)。而通过WebGL来实现时由于机制上的一些优势,能提升不少性能:

  1. WebGL中对图形应用变换时,只需要将变换后的顶点坐标传给gl_Position变量即可,换而言之,在已经计算出变形后顶点的情况下就不需要再计算变换矩阵了,所以基于WebGL来实现变形时可以节省计算变换矩阵的时间。
  2. 基于Canvas 2D实现时,有多少个三角形就得绘制多少次;而WebGL中可以将数据保存在缓冲区,然后一次绘制多个图形,所以能极大地缩短绘制时间。
  3. WebGL中多个三角形图元上绘制纹理贴图并拼接后不会出现上面的线框问题,自然也不用花费额外时间来计算迂回边之类的了。

3.1 计算顶点、纹理坐标及顶点索引数据

这一步可以利用之前实现的函数handleCurvePoints来生成顶点数据。这里先说一下顶点的顺序,也就是绘制的三角形的顶点顺序。首先和Canvas版一样,也是按照从左到右、从上到下的顺序遍历所有四边形(变形前是矩形,变形后是梯形),四边形也是拆分为左上、右下两个三角形。左上、右上、左下三个顶点确定左上三角形;右上、左下、右下三个顶点确定右下三角形,如下图所示:

顶点和纹理坐标都是按照上面的方式排列,然后根据WebGL中的两个绘制方法,存在两种坐标数据的组织方式,这里优先采用drawElements(基于该方法的实现性能要比drawArrays好一点,大概是因为数据量小一点,所以处理要会快一点,我猜的)。对于弯曲变形后的顶点坐标数据,首先将传入handleCurvePoints函数的四个顶点转换到NDC(标准设备空间)下,然后传入一个回调将所有顶点依次保存到一个Float32Array中;对于纹理坐标数据,根据分段数量就可以计算出来。因为是基于drawElements实现,所以还需要计算索引数据,也就是上图所示的顶点排列规律求出。

这部分计算的主要代码
/**
 * 将画布坐标系下矩形四个顶点转换到NDC(标准设备坐标系)
 * @param pa 矩形左上角顶点
 * @param pb 矩形右上角顶点
 * @param pc 矩形右下角顶点
 * @param pd 矩形左下角顶点
 * @param cvsWidth 画布宽度
 * @param cvsHeight 画布高度
 * @returns 转换后的四个顶点坐标组成的数组
 */
export function computeNDCEndPositions(
  pa: Point2D,
  pb: Point2D,
  pc: Point2D,
  pd: Point2D,
  cvsWidth: number,
  cvsHeight: number
): [Point2D, Point2D, Point2D, Point2D] {
  const center: Point2D = { x: cvsWidth / 2, y: cvsHeight / 2 };
  return [
    computeNDCPosition(pa, center),
    computeNDCPosition(pb, center),
    computeNDCPosition(pc, center),
    computeNDCPosition(pd, center),
  ];
}

/**
 * 将顶点从画布坐标系转换到NDC——x坐标由[0, cvsWidth]映射到[-1, 1];y坐标由[0, cvsHeight]映射到[-1, 1]
 * @param point 画布坐标系下的顶点
 * @param center 画布坐标系下的中心点
 * @returns 转换后的顶点坐标
 */
function computeNDCPosition(point: Point2D, center: Point2D) {
  let { x, y } = point;
  const { x: cx, y: cy } = center;
  x = (x - cx) / cx;
  y = (cy - y) / cy;
  return { x, y };
}

/**
 * 根据传入的矩形区域的三个顶点数据计算出分段后所有顶点数据并保存到类型化数组中(此函数保存的顶点数据用于drawElements绘制)
 * @param pa 左上顶点
 * @param pb 右上顶点
 * @param pd 左下顶点
 * @param vertices 保存顶点数据的类型数组
 * @param xCount 水平方向分段数量
 * @param yCount 垂直方向分段数量
 * @param flip 是否翻转y轴
 */
export function updateRectanglePoints(
  pa: Point2D,
  pb: Point2D,
  pd: Point2D,
  vertices: Float32Array,
  xCount: number,
  yCount = xCount,
  flip = false,
) {
  const { x, y } = pa;
  const yStep = (pd.y - pa.y) / yCount;
  const xStep = (pb.x - pa.x) / xCount;
  let endYIndex = flip ? -1 : yCount + 1,
    yIndexDelta = flip ? -1 : 1,
    index = 0;
  for (let i = flip ? yCount : 0; i !== endYIndex; i += yIndexDelta) {
    for (let j = 0; j <= xCount; ++j) {
      const pX = x + j * xStep;
      const pY = y + i * yStep;

      vertices[index++] = pX;
      vertices[index++] = pY;
    }
  }
}

/**
 * 计算顶点索引数据并保存到传入的类型化数组中
 * @param vertexIndices 索引数组
 * @param xCount 水平分段数量
 * @param yCount 垂直分段数量
 */
export function updateVertexIndices(
  vertexIndices: Uint32Array,
  xCount: number,
  yCount = xCount
) {
  let index = 0;
  for (let i = 0; i < yCount; ++i) {
    for (let j = 0; j < xCount; ++j) {
      // 四个顶点的索引,依次是左上、右上、左下、右下
      const p1 = i * (xCount + 1) + j;
      const p2 = p1 + 1;
      const p3 = p2 + xCount;
      const p4 = p3 + 1;

      // 保存左上三角形三个顶点的索引值
      vertexIndices[index++] = p1;
      vertexIndices[index++] = p2;
      vertexIndices[index++] = p3;

      // 保存右下三角形三个顶点的索引值
      vertexIndices[index++] = p2;
      vertexIndices[index++] = p3;
      vertexIndices[index++] = p4;
    }
  }
}

上面的代码中并没有处理变形后顶点数据的函数的代码,因为基于drawElements的实现中,顶点数据的主要逻辑在handleCurvePoints中,而只需要传入一个简单的回调保存顶点即可。之前还不太懂的时候是基于drawArrays实现的,这是不推荐的实现方式(这种实现的完整代码demo项目中还保留着),因为不但数据量较大(保存数据的数组中含有大量重复的顶点数据),而且保存顶点数据的逻辑还比较复杂——根据顶点在水平/垂直排列上的索引计算它在数组中索引时有九种情况,并且计算量还不小,具体逻辑不再详细描述,不过可以看下代码感受下:

基于drawArrays实现时,生成保存顶点数据的回调(传入handleCurvePoints的回调)的函数
/**
 * 生成用于更新变形后图像中分段三角形顶点数据的回调
 * @param curveVertices 保存顶点数据的类型数组
 * @param xCount x方向分段数量
 * @param yCount y方向分段数量
 * @returns 更新变形后分段三角形顶点数据的回调
 */
export function genVerticesUpdater(
  curveVertices: Float32Array,
  xCount = 10,
  yCount = xCount
): PointCallback {
  /**
   * 更新顶点数据的回调
   * @param x 顶点x坐标值
   * @param y 顶点y坐标值
   * @param vertexYIndex 顶点y坐标值在类型数组中的索引位置
   */
  const updateVertices = (x: number, y: number, vertexYIndex: number) => {
    curveVertices[vertexYIndex - 1] = x;
    curveVertices[vertexYIndex] = y;
  };
  /**
   * 接收顶点数据并根据顶点的位置以及相应规则更新类型数组的回调
   * @param x 顶点x坐标值
   * @param y 顶点y坐标值
   * @param xIndex 顶点按水平排列的索引
   * @param yIndex 顶点按垂直排列的索引
   */
  return (x: number, y: number, xIndex: number, yIndex: number) => {
    // 非边缘上的顶点,求顶点数据的索引的规则参考:
    if (xIndex > 0 && xIndex < xCount && yIndex > 0 && yIndex < yCount) {
      const c1 = 12 * yIndex * xCount + 12 * xIndex,
        c0 = c1 - 12 * xCount;
      updateVertices(x, y, c0 - 1);
      updateVertices(x, y, c0 + 5);
      updateVertices(x, y, c0 + 9);
      updateVertices(x, y, c1 - 9);
      updateVertices(x, y, c1 - 5);
      updateVertices(x, y, c1 + 1);
      // 左上角的顶点
    } else if (xIndex === 0 && yIndex === 0) {
      updateVertices(x, y, 1);
      // 右下角的顶点
    } else if (xIndex === xCount && yIndex === yCount) {
      updateVertices(x, y, xCount * yCount * 12 - 1);
      // 右上角的顶点
    } else if (xIndex === xCount && yIndex === 0) {
      const c0 = 12 * xIndex;
      updateVertices(x, y, c0 - 9);
      updateVertices(x, y, c0 - 5);
      // 左下角的顶点
    } else if (xIndex === 0 && yIndex === yCount) {
      const c0 = 12 * xCount * yIndex - 12 * xCount;
      updateVertices(x, y, c0 + 5);
      updateVertices(x, y, c0 + 9);
      // 上边缘上的顶点(不包括角点)
    } else if (yIndex === 0 && xIndex > 0 && xIndex < xCount) {
      const c0 = 12 * xIndex;
      updateVertices(x, y, c0 - 9);
      updateVertices(x, y, c0 - 5);
      updateVertices(x, y, c0 + 1);
      // 下边缘上的顶点(不包含角点)
    } else if (yIndex === yCount && xIndex > 0 && xIndex < xCount) {
      const c0 = 12 * xCount * yIndex - 12 * xCount + 12 * xIndex;
      updateVertices(x, y, c0 - 1);
      updateVertices(x, y, c0 + 5);
      updateVertices(x, y, c0 + 9);
      // 左边缘上的顶点(不包含角点)
    } else if (xIndex === 0 && yIndex > 0 && yIndex < yCount) {
      const c1 = 12 * xCount * yIndex,
        c0 = c1 - 12 * xCount;
      updateVertices(x, y, c0 + 5);
      updateVertices(x, y, c0 + 9);
      updateVertices(x, y, c1 + 1);
      // 右边缘上的顶点(不包含角点)
    } else {
      const c0 = 12 * xCount * yIndex,
        c1 = c0 + 12 * xCount;
      updateVertices(x, y, c0 - 1);
      updateVertices(x, y, c1 - 9);
      updateVertices(x, y, c1 - 5);
    }
  };
}

其实关于计算顶点和纹理坐标数据这一步,做过很多次优化,刚开始时的逻辑是比较蠢的(感觉是由于个人的思维定势导致的):

  1. 计算出所有顶点,并保存在一个数组(Array)中,最后返回该数组。
  2. 遍历所有顶点,将它们转换为NDC下的坐标。
  3. 遍历所有四边形的左上顶点,将顶点数据保存在数组中,最后返回基于该数组创建的一个类型化数组(TypedArray)。

上面三步中第一步对性能的影响是最大的(分段数量较大时才明显),具体而言,第一步的逻辑是在handleCurvePoints中,优化之前是叫computeCurvePoints(优化后拆分成两个函数),这时不是通过pointCallback回调保存顶点数据(改为回调为了灵活应对不同的需求),而是内部创建了一个空数组,每次产生的顶点数据都push到数组中,最后返回该数组。问题主要出在数组(Array)这里,普通数组保存/更新数据的性能比类型化数组差得多,因为类型化数组是直接操作内存数据,不用进行类型转化,所以要快得多。后面两步的优化前面的代码已经展示过了——第二步的坐标转换放到了第一步之前,即先对四个顶点转换,再传入到函数;而最后一步的处理也放到第一步,即通过pointCallback将顶点数据直接保存在类型化数组中。经过这些优化,可以很大程度降低顶点数据计算的时间频度。

3.2 进行初始化工作

WebGL版实现中,函数柯里化是一个重要的优化方式,将整个渲染流程中初始化的部分抽离出来只执行一次,而将重新计算和渲染的逻辑放在返回的回调中,这样就能提升一定性能。初始化阶段的流程如下:

  1. 获取绘制上下文。
  2. 进行元素索引精度扩展。
  3. 初始化着色器。
  4. 进行纹理初始化。
  5. 返回一个回调,接收顶点索引、纹理坐标等数据并进行缓冲区初始化、将顶点索引、纹理坐标数据传递到缓冲区,返回一个进行绘制的回调,在该回调中还会接收顶点数据(该数据更新较频繁)并传递到缓冲区。
初始化阶段的主要代码
import { bindArrayBuffer, initTexture } from '@/utils/gl-bind';

const VSHADER_SOURCE = `
attribute vec4 a_Position;
attribute vec2 a_TexCoord;
varying vec2 v_TexCoord;
void main() {
    gl_Position = a_Position;
    v_TexCoord = a_TexCoord;
}
`;
const FSHADER_SOURCE = `
precision highp float;
uniform sampler2D u_Sampler;
varying vec2 v_TexCoord;
void main() {
    vec4 color = texture2D(u_Sampler, v_TexCoord);
    gl_FragColor = color;
}
`;

export function initTextureRenderer(
  cvs: HTMLCanvasElement,
  image: TexImageSource
) {
  const gl = getWebGLContext(cvs);
  if (!gl) {
    return console.error('获取WebGL绘制上下文失败!');
  }

  if (!gl.getExtension('OES_element_index_uint')) {
    return console.error('您的浏览器具有收藏价值!');
  }

  if (!initShaders(gl, VSHADER_SOURCE, FSHADER_SOURCE)) {
    return console.error('着色器初始化失败!');
  }

  if (!initTexture(gl, image)) {
    return console.error('纹理初始化失败!');
  }

  return (
    pointIndices: Uint32Array,
    texCoords: Float32Array,
    numberOfVertex: number
  ) => {
    // 重新创建缓冲对象,以触发浏览器垃圾回收
    const initBufferResult = initVerticesAndCoordsBuffer(gl);
    if (!initBufferResult) {
      return console.error('初始化缓冲区对象失败!');
    }
    const { verticesBuffer, coordsBuffer, indexBuffer } = initBufferResult;
    gl.bindBuffer(gl.ARRAY_BUFFER, coordsBuffer);
    gl.bufferData(gl.ARRAY_BUFFER, texCoords, gl.DYNAMIC_DRAW);

    gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexBuffer);
    gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, pointIndices, gl.DYNAMIC_DRAW);

    return (vertices: Float32Array) => {
      gl.bindBuffer(gl.ARRAY_BUFFER, verticesBuffer);
      gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.DYNAMIC_DRAW);

      gl.clear(gl.COLOR_BUFFER_BIT);
      gl.drawElements(gl.TRIANGLES, numberOfVertex, gl.UNSIGNED_INT, 0);
    };
  };
}

function initVerticesAndCoordsBuffer(gl: WebGLRenderingContext) {
  const aPosition = gl.getAttribLocation(gl.program, 'a_Position');
  const aTexCoord = gl.getAttribLocation(gl.program, 'a_TexCoord');
  if (!~aPosition || !~aTexCoord) {
    console.error('获取attribute变量存储位置失败!');
    return null;
  }
  const verticesBuffer = gl.createBuffer(),
    coordsBuffer = gl.createBuffer(),
    indexBuffer = gl.createBuffer();
  if (!verticesBuffer || !coordsBuffer || !indexBuffer) {
    console.error('创建缓冲区对象失败!');
    return null;
  }
  bindArrayBuffer(gl, verticesBuffer, aPosition);
  bindArrayBuffer(gl, coordsBuffer, aTexCoord);

  return { verticesBuffer, coordsBuffer, indexBuffer };
}

export function bindArrayBuffer(
  gl: WebGLRenderingContext,
  buffer: WebGLBuffer,
  attrib: number,
  pointNum = 2
) {
  gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
  gl.vertexAttribPointer(attrib, pointNum, gl.FLOAT, false, 0, 0);
  gl.enableVertexAttribArray(attrib);
}

export function initTexture(gl: WebGLRenderingContext, image: TexImageSource) {
  const texture = gl.createTexture();
  if (!texture) {
    console.error('创建纹理对象失败!');
    return false;
  }

  const u_Sampler = gl.getUniformLocation(gl.program, 'u_Sampler');
  if (!u_Sampler) {
    console.error('获取取样器变量存储位置失败!');
    return false;
  }
  gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, 1);
  gl.activeTexture(gl.TEXTURE0);
  gl.bindTexture(gl.TEXTURE_2D, texture);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
  gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image);
  gl.uniform1i(u_Sampler, 0);
  return true;
}

3.3 生成计算和绘制的回调

在进行上面的初始化之后,得到一个回调renderGenerator,renderGenerator用于接收并更新顶点索引数据、纹理坐标数据、顶点数量等参数,这些参数受水平分段数量和垂直分段数量这两个参数的影响,也就是当两种分段数量发生变化时需要重新计算这些数据参数并执行renderGenerator,那么这部分逻辑可以放到一个回调C1中;而renderGenerator的返回值render,是一个用于接收、更新顶点数据并进行绘制的回调,顶点数据受弯曲角度的影响,同样这部分逻辑可以放在C1返回的回调中。

生成计算和绘制回调的函数
/**
 * 初始化绘制弯曲变形的图像的上下文并生成绘制回调
 * @param cvs 画布DOM
 * @param textPicture 文本图像资源
 * @param textRect 文本绘制的位置、尺寸
 * @returns 返回接收x/y方向分段数量参数的回调
 */
export function initDrawingCurveText(
  cvs: HTMLCanvasElement,
  textPicture: HTMLImageElement | ImageBitmap,
  textRect: TextRect
) {
  const { x, y, width, height } = textRect;
  const pa: Point2D = { x, y },
    pb: Point2D = { x: x + width, y },
    pc: Point2D = { x: x + width, y: y + height },
    pd: Point2D = { x, y: y + height };
  return initDrawingCurveImage(cvs, pa, pb, pc, pd, textPicture, true);
}

/**
 * 初始化绘制弯曲变形的图像的上下文并生成绘制回调,采用了函数柯里化以优化性能
 * @param cvs 画布DOM
 * @param pa 原始图像矩形区域左上顶点坐标
 * @param pb 原始图像矩形区域右上顶点坐标
 * @param pc 原始图像矩形区域右下顶点坐标
 * @param pd 原始图像矩形区域左下顶点坐标
 * @param img 原始图像资源
 * @param flip 是否反转图像y坐标
 * @returns 返回接收x/y方向分段数量参数的回调,该回调的执行结果为一个接收弯曲角度参数的绘制弯曲后图像的回调
 */
export function initDrawingCurveImage(
  cvs: HTMLCanvasElement,
  pa: Point2D,
  pb: Point2D,
  pc: Point2D,
  pd: Point2D,
  img: HTMLImageElement | ImageBitmap,
  flip = false
) {
  [pa, pb, pc, pd] = computeNDCEndPositions(
    pa,
    pb,
    pc,
    pd,
    cvs.width,
    cvs.height
  );
  const tl = { x: 0, y: 1 },
    tr = { x: 1, y: 1 },
    bl = { x: 0, y: 0 };
  const widthHeightRatio = cvs.width / cvs.height;
  // 初始化gl绘制上下文,生成一个接收顶点数据和纹理坐标数据的绘制回调
  const renderGenerator = initTextureRenderer(cvs, img);
  return (xCount: number, yCount = xCount) => {
    const numberOfVertex = xCount * yCount * 6;
    const pointIndices = new Uint32Array(numberOfVertex);
    const numberOfPoints = (xCount + 1) * (yCount + 1) * 2;
    const vertices = new Float32Array(numberOfPoints);
    const coords = new Float32Array(vertices);
    updateRectanglePoints(tl, tr, bl, coords, xCount, yCount, flip);
    updateVertexIndices(pointIndices, xCount, yCount);
    const render = renderGenerator?.(pointIndices, coords, numberOfVertex);
    return (angle: number) => {
      // 暂不考虑大于180°或小于-180°的情况
      if (angle > 180 || angle < -180) {
        return;
      }
      // console.time('draw gl1');
      // 弯曲角度为0则返回原始图像矩形区域的所有端点
      if (angle === 0) {
        updateRectanglePoints(pa, pb, pd, vertices, xCount, yCount);
      } else {
        // console.time('handleCurvePoints')
        let index = 0;
        handleCurvePoints(
          pa,
          pb,
          pc,
          pd,
          angle,
          (x: number, y: number) => {
            vertices[index++] = x;
            vertices[index++] = y;
          },
          xCount,
          yCount,
          -1,
          widthHeightRatio
        );
        // console.timeEnd('handleCurvePoints')
      }
      render?.(vertices);
      // console.timeEnd('draw gl1');
    };
  };
}

下面是这一版实现的分段数达到500*500后每次(调整弯曲角度过程中)渲染时间:

从呈现效果上看还是比较流畅的,而且其实在分段数达到1000*1000后,才会有和2D版50*50一样的拉跨表现,所以说WebGL版比Canvas 2D板快400倍(当然,和机器的配置有关)不过分吧。另外,这里说明一下,当打开F12开发者工具后,WebGL实现在调整弯曲角度时的绘制会出现较明显的掉帧(chrome中比较明显,firefox不明显但绘制要慢一点),具体是啥原因还不清楚,如果有知道原因的朋友,希望能赐教。

3.4 将顶点计算放到着色器中

细心的朋友可能已经注意到了,上面顶点着色器中逻辑非常简单,只是简单的赋值,而顶点的计算放在了JS中,这样的话没法发挥着色器并行运行的优势。所以,可以进一步优化,将顶点的计算放到顶点着色器中,而纹理坐标只会在分段数发生变化时才改变,所以可以放在JS中(实际运用场景中分段数不会频繁发生变化)。

关于如何在着色器中计算顶点坐标,首先回顾一下2.2节中计算计算顶点坐标的公式:

P.x=O.x+Rsin(sβ)P.y=O.y+Rcos(sβ)d\begin{aligned} & P.x = O.x + R\sin(-s\beta)\\ & P.y = O.y + R\cos(-s\beta)d \end{aligned}

上面公式中,圆心O的坐标可以直接传入,因为和顶点索引位置无关,这里的索引位置是二维的,分为水平索引和垂直索引,下面是水平分段数为5,垂直分段数为6,即5*6的情况下顶点的索引位置情况:

上图中箭头指向的顶点的索引位置就是(3,5)。之所以将索引位置定义为二维的是因为不同维度的索引影响不同的参数,可以用水平索引计算公式中的角度β: 上图中水平索引为1的顶点的角度(与原点到圆弧终点连线间的角度)等于起始角度(左上索引为0处顶点的角度)加上索引值(1)乘以角度变化步长(100/5,即20°)得到。 而垂直索引可以用来计算半径: 上图中垂直索引为1处点所在圆弧半径等于左上顶点的半径减去垂直索引(1)乘以半径变化步长(左上顶点和左下顶点之间的距离除以垂直分段数)得到。

另外,实际计算顶点y坐标时还要考虑弯曲角度的符号以及宽高比,所以最终得到的顶点着色器中的代码如下:

// 左上顶点的半径长度
uniform float u_UpRadius;
// 半径变化步长
uniform float u_RadiusDelta;
// 中心点的坐标
uniform vec2 u_Center;
// 起始角度
uniform float u_FromAngle;
// 角度变化步长
uniform float u_AngleStep;
// 弯曲方向——弯曲的角度的符号,+1或-1
uniform float u_CurveDir;
// 画布宽高比
uniform float u_WidthHeightRatio;
// 是否使用传入的顶点
uniform bool u_UsePos;
// 传入的计算好的顶点坐标(未变形时)
attribute vec4 a_Position;
// 顶点索引位置
attribute vec2 a_PosIndices;
// 纹理坐标
attribute vec2 a_TexCoord;
// varying纹理坐标
varying vec2 v_TexCoord;
void main() {
  // 弯曲角度为0时u_UsePos才会true,此时无法通过下面的计算得到顶点的位置(半径为∞),所以使用变形前的顶点位置即可。
  if (u_UsePos) {
    gl_Position = a_Position;
  } else {
    float radius = u_UpRadius - a_PosIndices.y * u_RadiusDelta;
    float angle = u_FromAngle + a_PosIndices.x * u_AngleStep;
    float x = u_Center.x + radius * sin(angle);
    float y = (u_Center.y + radius * cos(angle) * u_CurveDir) * u_WidthHeightRatio;
    gl_Position = vec4(x, y, 0, 1);
  }
  v_TexCoord = a_TexCoord;
}

上面着色器中的u_UpRadius,u_RadiusDelta,u_Center,u_fromAngle,u_AngleStep,u_CurveDir等变量需要单独计算,它们都受弯曲角度的影响。

计算上面参数的函数
/**
 * 计算求圆弧上顶点需要的参数(这些参数用于在顶点着色器中基于顶点索引计算顶点新位置)
 * @param pa 原始图像矩形区域左上顶点坐标
 * @param pb 原始图像矩形区域右上顶点坐标
 * @param pc 原始图像矩形区域右下顶点坐标
 * @param pd 原始图像矩形区域左下顶点坐标
 * @param angle 弯曲的角度
 * @param xCount 水平方向分段数量
 * @param yCount 垂直方向分段数量
 * @param yDir ++y轴方向——1表示向下,-1则相反
 * @param widthHeightRatio 宽高比
 * @returns 求圆弧上顶点所需的参数
 */
export function computeCurveParams(
  pa: Point2D,
  pb: Point2D,
  pc: Point2D,
  pd: Point2D,
  angle: number,
  xCount: number,
  yCount: number,
  yDir: CoordDirection,
  widthHeightRatio: number
): CurveParams {
  angle = angleToRadian(angle);
  const rotateDir = sign(angle);
  // 是否反向弯曲
  const isOpposite = rotateDir === -1;
  // pa和pd组成向量旋转后的向量
  const vectorAD = computeRotatedVector(
    pa,
    pd,
    angle,
    -yDir as CoordDirection,
    widthHeightRatio
  );
  // pb和pc组成向量旋转后的向量
  const vectorBC = computeRotatedVector(pb, pc, angle, yDir, widthHeightRatio);

  // 最上方圆弧左端点
  const leftEndPoint = isOpposite ? pa : addPoint2D(pd, vectorAD);
  // 最上方圆弧右端点
  const rightEndPoint = isOpposite ? pb : addPoint2D(pc, vectorBC);
  // 弯曲起始角度、弯曲角度变化步长、弯曲方向、左右两端点的组成向量相对于水平方向的偏移角度
  const {
    from: fromAngle,
    realStep: angleStep,
    curveDir,
    offsetRad,
  } = computeAngleParams(
    leftEndPoint,
    rightEndPoint,
    angle,
    xCount,
    yDir,
    widthHeightRatio
  );
  // 最上方圆弧的半径、中心点
  const { radius: upRadius, center } = computeArcParams(
    leftEndPoint,
    rightEndPoint,
    angle,
    yDir,
    widthHeightRatio,
    offsetRad
  );
  // 半径变化步长
  const radiusDelta =
    (rotateDir * hypot(vectorAD.x, vectorAD.y / widthHeightRatio)) / yCount;
  return {
    upRadius,
    radiusDelta,
    center,
    fromAngle,
    angleStep,
    curveDir,
  };
}

将顶点计算移到着色器的详细改动请参考demo项目,下面是优化后分段达到2000*2000后每次(调整弯曲角度过程中)渲染时间:

可以看得出算是效果拔群了,不过将顶点计算放到着色器中的话就很看GPU性能了(严谨一点讲,我也不清楚是如何影响的,我的理解就是并行数和GPU流处理元数量有关,顶点数过多时并发数会远大于并行数,此时性能会有较明显的下降),再增加分段数的话(实际场景中肯定要限制分段数上线的,而且也会有确定分段数的逻辑),差不多达到4000*4000的分段数量(我的机器的配置:i3-8100,16G,核显)才会出现上面的那种拉跨表现,此时内存占用也会比较高。

3. 源码地址

这里放一下demo项目地址以及备用地址,里面包含上面出现过的所有代码。如果比较感兴趣或对您有帮助的话,劳烦给颗星支持一下,感激不尽🤣