如何实现一个Canvas渲染引擎(五):webGL渲染(Part 1)

友情提示

  1. 请先看这里 👉 链接
  2. 阅读本文需要有一些webGL知识,学习webGL基础可以看这里 👉 WebGL Fundamentals
  3. 由于webGL的内容比较多,所以我打算分成2篇文章来阐述,本文是第一篇
  4. 本文是纯理论部分,主要讲述如何对图形进行顶点化处理和三角剖分,当然,代码也会有一些

1. 前言

在2011年左右,webGL登陆了浏览器,在这之前,我们已经可以用canvas来构建高性能(相较于DOM)的web端应用了,虽然canvas相对于DOM比较底层一些,但是canvas始终是封装程度较高的API,其距离浏览器理论上能实现的最高性能还是有一定的差距的,webGL的出现,则让浏览器能够实现的渲染性能朝着理论上的最高性能前进了一大步。

2. 什么是webGL

webGL只是一个光栅化引擎

我们给webGL3个顶点,虽然称之为顶点,但是它不仅仅只包含了坐标信息,它也可以包含颜色信息、法向量信息(在3D中会用到)等,接下来webGL会将这3个顶点围成的三角形光栅化,形成一系列的片元(可以简单理解为像素点),在光栅化的同时,会对这三个顶点的其他信息比如颜色信息等进行线性插值,形成一堆颜色信息,然后交给每个片元;对于每个片元,都会执行一次片元着色器,我们可以在片元着色器里,执行一些逻辑,最后返回一个颜色,告诉webGL这个片元要用这种颜色着色。

webGL的核心就是线性插值,光栅化这个过程就是一个线性插值的过程(在3个顶点的坐标之间进行线性插值);所以我们也可以将webGL理解为一个线性插值引擎,利用它的线性插值功能可以实现很多功能,不仅仅是对颜色进行线性插值,来实现颜色填充,也可以对贴图坐标进行线性插值,来实现贴图填充。

当然,我们不可能只给webGL3个顶点的,一般我们会给webGL一个顶点数组,这个数组里面每3个作为一组,webGL会依次处理每个组,绘制出一堆三角形出来。

简单来说,我们给webGL一系列的顶点,然后webGL会帮我们画出一堆三角形出来,当然,webGL也可以画线和点,但是本文只会用到三角形,所以线和点不在本文的讨论范围内。

3. webGL和canvas的区别

canvas可以理解成webGL的一层封装,它支持绘制圆、矩形等简单图形,我们也可以通过路径,来实现复杂图形的绘制,并且可以对这些图形进行颜色填充(ctx.fill)和描边(ctx.stroke),而webGL,只能画三角形,无论是什么图形,我们都将用三角形来进行近似,这意味着,canvas提供的所有绘制功能,我们都必须要手动实现,所以工作量会大很多,但是,webGL带来的性能提升,是十分巨大的,这也是为什么在这个渲染引擎里要实现webGL渲染。

4. 从一个简单的例子开始理解顶点化和三角剖分

既然webGL只能画三角形,那我们首先要做的就是把任何图形转换成三角形,这个过程分为了2步:顶点化和三角剖分。

以最常见的图形:矩形为例,我们调用api在画布上绘制了一个这样的矩形:

typescript 复制代码
const g = new Graphics().beginFill('black').drawRect(100, 100, 200, 100)
app.stage.addChild(g)

在webGL模式下,我们会如何处理这个矩形呢?首先是顶点化,矩形的顶点化十分简单,因为它的四个点就是我们要的顶点,所以我们得到了如下的顶点数组:

json 复制代码
[100, 100, 300, 100, 300, 200, 100, 200]

在画布上绘制出来是这样的4个点:

顶点化就处理完毕了,接下来是三角剖分(triangulate)三角剖分做的事情就是把图形处理成一系列的三角形,我们会从上面得到的顶点数组出发,从这个顶点数组生成一个顶点下标数组顶点下标数组的每一个元素代表顶点的(nth-1),比如,0代表第一个顶点,1代表第二个顶点;顶点下标数组的每3个元素为一组,代表一个三角形的三个顶点。

矩形的三角剖分也非常简单,我们需要将其剖分成2个三角形,第一个三角形的三个顶点分别是矩形的第1、2、3个顶点,对应的顶点下标是0,1,2,同理,第二个三角形的顶点下标是0,2,3;当然,第一个三角形是3,0,1,第二个三角形是1,2,3也行,只要能保证这两个三角形能够盖住这个矩形区域。

我们得到的顶点下标数组是:

json 复制代码
[0, 1, 2, 0 ,2, 3]

最后我们会得到这样两个三角形:

以上这个例子讲述了一个简单的顶点化和三角剖分,接下来要处理各种图形的顶点化和三角剖分

5. 各种图形的顶点化和三角剖分

5.1 矩形

矩形的三角剖分理论在第3节讲了,所以这里直接上代码

5.1.1 顶点化

typescript 复制代码
function buildRectVertices(rect: Rectangle) {
  const vertices: number[] = []

  const { x, y, width, height } = rect

  vertices.push(x, y, x + width, y, x + width, y + height, x, y + height)

  return vertices
}

5.1.2 三角剖分

typescript 复制代码
function triangulateRect() {
  return [0, 1, 2, 0, 2, 3]
}

5.2 圆

5.2.1 顶点化

在圆弧上每隔一段距离取一个点,就得到了圆的顶点数组。

代码:

typescript 复制代码
function buildCircleVertices(circle: Circle) {
  const vertices: number[] = []
  const { x, y, radius } = circle
  const len = 2 * Math.PI * radius
  const segmentCount = Math.min(Math.ceil(len / 5), 2048)

  for (let i = 0; i < segmentCount; i++) {
    const angle = (i / segmentCount) * Math.PI * 2
    const pX = x + radius * Math.cos(angle)
    const pY = y + radius * Math.sin(angle)
    vertices.push(pX, pY)
  }
  
  return vertices
}

5.2.2 三角剖分

对于圆这种凸多边形,我们的处理方式是,把它的中心点push到顶点数组里,然后遍历圆弧上的点,每2个圆弧上的点加上中心点,就得到了一个三角形。

代码:

typescript 复制代码
// 在执行这个函数之前需要将中心点push到顶点数组
function triangulateCircle(vertices: number[]) {
  const indices: number[] = []

  const len = vertices.length / 2

  for (let i = 1; i < len - 1; i++) {
    indices.push(0, i, i + 1)
  }

  // 还有最后一块
  indices.push(0, len - 1, 1)

  return indices
}

5.2.3 测试一下

测试代码:

typescript 复制代码
const g = new Graphics().beginFill('black').drawCircle(300, 200, 100)
app.stage.addChild(g)

顶点化:

三角剖分:

可以看到,一个圆是由多个三角形组成的,每个三角形都有一个顶点落在圆心上。

为了让大家看得更清楚,我将其放大了几倍:

5.3 椭圆

椭圆的处理逻辑和圆差不多

5.3.1 顶点化

在椭圆圆弧上每隔一段距离取一个点,就得到了椭圆的顶点数组。

代码:

typescript 复制代码
function buildEllipseVertices(ellipse: Ellipse) {
  const vertices: number[] = []
  const { x, y, radiusX, radiusY } = ellipse

  const len = Math.PI * Math.sqrt(2 * (radiusX * radiusX + radiusY * radiusY))
  const segmentCount = Math.min(Math.ceil(len / 5), 2048)

  for (let i = 0; i < segmentCount; i++) {
    const angle = (i / segmentCount) * Math.PI * 2
    const pX = x + radiusX * Math.cos(angle)
    const pY = y + radiusY * Math.sin(angle)
    vertices.push(pX, pY)
  }

  return vertices
}

5.3.2 三角剖分

椭圆也是凸多边形,所以处理方式和圆类似,把它的中心点push到顶点数组里,然后遍历椭圆圆弧上的点,每2个椭圆圆弧上的点加上中心点,就得到了一个三角形。

代码(和圆的三角剖分的代码一样):

typescript 复制代码
// 在执行这个函数之前需要将中心点push到顶点数组
function triangulateCircle(vertices: number[]) {
  const indices: number[] = []

  const len = vertices.length / 2

  for (let i = 1; i < len - 1; i++) {
    indices.push(0, i, i + 1)
  }

  // 还有最后一块
  indices.push(0, len - 1, 1)

  return indices
}

5.3.3 测试一下

测试代码:

typescript 复制代码
const g = new Graphics().beginFill('black').drawEllipse(300, 200, 150, 80)
app.stage.addChild(g)

顶点化:

三角剖分:

三角剖分放大后:

5.4 圆角矩形

圆角矩形等于4条线段+4个圆弧,所以处理逻辑依然是跟圆差不多,我们在4个圆弧上取一些点,就得到了圆角矩形的顶点数组

5.4.1 顶点化

代码:

typescript 复制代码
function buildRoundedRectangleVertices(roundedRectangle: RoundedRectangle) {
  const vertices: number[] = []
  const { x, y, width, height, radius } = roundedRectangle

  // 分4段来求
  const len = (2 * Math.PI * radius) / 4
  const segmentCount = Math.min(Math.ceil(len / 4), 2048)

  // 第一段圆弧(圆角矩形的右下角)
  for (let i = 0; i < segmentCount; i++) {
    const angle = (i / segmentCount) * Math.PI * 0.5

    const pX = radius * Math.cos(angle)
    const pY = radius * Math.sin(angle)

    vertices.push(x + width - radius + pX, y + height - radius + pY)
  }

  // 第二段圆弧(圆角矩形的左下角)
  for (let i = 0; i < segmentCount; i++) {
    const angle = (i / segmentCount) * Math.PI * 0.5 + Math.PI / 2

    const pX = radius * Math.cos(angle)
    const pY = radius * Math.sin(angle)

    vertices.push(x + radius + pX, y + height - radius + pY)
  }

  // 第三段圆弧(圆角矩形的左上角)
  for (let i = 0; i < segmentCount; i++) {
    const angle = (i / segmentCount) * Math.PI * 0.5 + Math.PI

    const pX = radius * Math.cos(angle)
    const pY = radius * Math.sin(angle)

    vertices.push(x + radius + pX, y + radius + pY)
  }

  // 第四段圆弧(圆角矩形的右上角)
  for (let i = 0; i < segmentCount; i++) {
    const angle = (i / segmentCount) * Math.PI * 0.5 + Math.PI * 1.5

    const pX = radius * Math.cos(angle)
    const pY = radius * Math.sin(angle)

    vertices.push(x + width - radius + pX, y + radius + pY)
  }

  return vertices
}

5.4.2 三角剖分

圆角矩形也是凸多边形,所以处理方式和圆类似,先把它的中心点push到顶点数组里,然后遍历4条圆弧上的点,每2个圆弧上的点加上中心点,就得到了一个三角形。

代码(和圆的三角剖分的代码一样):

typescript 复制代码
// 在执行这个函数之前需要将中心点push到顶点数组
function triangulateCircle(vertices: number[]) {
  const indices: number[] = []

  const len = vertices.length / 2

  for (let i = 1; i < len - 1; i++) {
    indices.push(0, i, i + 1)
  }

  // 还有最后一块
  indices.push(0, len - 1, 1)

  return indices
}

5.4.3 测试一下

测试代码:

typescript 复制代码
const g = new Graphics().beginFill('black').drawRoundedRect(200, 100, 300, 200, 30)
app.stage.addChild(g)

顶点化:

三角剖分:

5.5 不规则多边形

5.5.1 顶点化

在这个渲染引擎里,用Polygon来表示多边形,Polygon类本身就包含了这个多边形的一系列顶点,所以多边形不需要进行顶点化。

5.5.2 三角剖分

不规则多边形可能包含凹多边形,凹多边形用简单的方式是无法处理的,所以需要借助三方库来进行处理,这里用earcut这个库来进行三角剖分

代码:

typescript 复制代码
import earcut from 'earcut'

function triangulatePolygon(vertices: number[]){
  return earcut(vertices)
}

5.5.3 测试一下

测试代码:

typescript 复制代码
const g = new Graphics()
  .beginFill('black')
  .drawPolygon([
    200, 300, 300, 100, 500, 100, 700, 300, 550, 550, 200, 450, 500, 470, 300,
    200
  ])
app.stage.addChild(g)

顶点化:

三角剖分:

6. 描边的顶点化和三角剖分

大家都知道DOM有border,canvas有stroke,也就是图形的描边,对于描边的处理也是用三角形来做的,没错,就算是一条宽度为1的线,也会用三角形来处理,而不是用webGL自带的线。
对于描边的处理,将会向canvas看齐。

6.1 lineCap的处理

canvas的lineCap有3种:"butt" | "round" | "square",其中"butt"是默认值,对于"butt"不需要做任何处理,只需要处理"round"和"square"。

6.1.1 lineCap === "square"

对于lineCap === "square",需要在线的首尾加上一个width为lineWidth / 2,height为lineWidth的矩形。

举个例子,假设在canvas上画了一条这样的线:

typescript 复制代码
ctx.beginPath()
ctx.lineCap = 'square'
ctx.lineWidth = 50
ctx.moveTo(200, 400)
ctx.lineTo(500,400)
ctx.stroke()

那么会形成如下图形:

红框内就是lineCap === 'square'时,加上的一个矩形, 对于这个图形的处理,非常简单,用2个三角形来做就行了,如下:

线条结尾的lineCap同理。

6.1.2 lineCap === "round"

对于lineCap === "round",需要在线的首尾加上一个半径为lineWidth / 2的半圆。

同样,假设我们在canvas上画了这样的线:

typescript 复制代码
ctx.beginPath()
ctx.lineCap = 'round'
ctx.lineWidth = 50
ctx.moveTo(200, 400)
ctx.lineTo(500,400)
ctx.stroke()

会得到如下图形:

红框内就是lineCap === 'round'时,加上的半圆

对于这个半圆的处理,跟上面讲述的对圆的处理是一样的(用n个扇形来近似),如下:

放大一点后是这样的:

线条结尾处的lineCap的处理同理。

6.2 lineJoin的处理

在canvas中,无论你怎么指定lineJoin,浏览器都会帮你加上一个lineJoin,也就是说,没有lineJoin==='none'这种说法的。

开始之前,我们先想想一个没有lineJoin的世界, 以如下代码为例:

typescript 复制代码
ctx.beginPath()
ctx.lineWidth = 50
ctx.moveTo(200, 400)
ctx.lineTo(500,400)
ctx.lineTo(600, 100)
ctx.stroke()

假设浏览器没有实现lineJoin,那么我们得到的线段会是一个这样的图形(为了突出两个线段的形状,我设置了一些透明度,并且为了便于看清线段的各个点,我将它们用红点标了出来):

上图的线段看起来非常突兀,因为好像缺了点什么,如果能将下面👇这个红框框住的缺口补全,是不是就会更好看了呢?

lineJoin的作用就是告诉浏览器,用何种方式补全这个红色区域。

6.2.1 lineJoin === "miter"(默认值)

miter就是作线段外侧的延长线,两条延长线会得到一个交点,然后填充一个四边形区域

下图👇红色区域就是要补全的区域:

对于这种lineJoin === 'miter',我们会这样剖分这个线段👇:

6.2.2 lineJoin === "bevel"

将两条线段外侧的顶点以及线段交点这3个点所围成的三角形填充

下图👇红色区域就是要补全的区域:

对于这种lineJoin === 'bevel',我们会这样剖分这个线段👇:

6.2.3 lineJoin === "round"

以两条线段的交点为圆心,画一个扇形

下图👇红色区域就是要补全的区域

对于这种lineJoin === 'round',我们会这样剖分这个线段👇:

线段本身会用3个三角形填充,然后拐角处的round区域用n个扇形填充,round区域放大后是这样的:

6.3 代码实现

6.3.1 求2条线段所在的直线的交点

这其实就是求一个线性方程组的解的问题,求线性方程组的解已经有公式了:

可以参考这篇文章:www.cnblogs.com/xpvincent/p...

代码:

typescript 复制代码
/**
 * @param p0x 线段1的第一个点的x
 * @param p0y 线段1的第一个点的y
 * @param p1x 线段1的第二个点的x
 * @param p1y 线段1的第二个点的y
 * @param p2x 线段2的第一个点的x
 * @param p2y 线段2的第一个点的y
 * @param p3x 线段2的第二个点的x
 * @param p3y 线段2的第二个点的y
 * @returns {number[]} 交点坐标
 */
const getIntersectingPoint = (
  p0x: number,
  p0y: number,
  p1x: number,
  p1y: number,
  p2x: number,
  p2y: number,
  p3x: number,
  p3y: number
): [number, number] => {
  let a = 0
  let b = 0
  let c = 0
  let d = 0
  let e = 0
  let f = 0

  if (Math.abs(p1x - p0x) <= Number.EPSILON) {
    // 是否是垂直线段
    // 垂直线段的解析式为x=a
    a = 1
    b = 0
    e = p1x
  } else {
    // 得到y = kx + b形式的解析式,就得到了a、b和e
    const k = (p1y - p0y) / (p1x - p0x)
    const b0 = p1y - k * p1x

    a = k
    b = -1
    e = -b0
  }

  // 同理
  if (Math.abs(p3x - p2x) <= Number.EPSILON) {
    c = 1
    d = 0
    f = p3x
  } else {
    const k = (p3y - p2y) / (p3x - p2x)
    const b0 = p3y - k * p3x

    c = k
    d = -1
    f = -b0
  }

  const x = (e * d - b * f) / (a * d - b * c)
  const y = (a * f - e * c) / (a * d - b * c)

  return [x, y]
}

6.3.2 构建顶点 & 三角剖分

代码:

typescript 复制代码
export const triangulateStroke = (
  data: GraphicsData,
  geometry: GraphicsGeometry
) => {
  const { vertices, shape, lineStyle } = data
  const {
    width: lineWidth,
    cap: lineCap,
    join: lineJoin,
    miterLimit
  } = lineStyle
  let cursor = 0

  let closedShape = false
  if (shape instanceof Polygon) {
    if (shape.closeStroke) {
      closedShape = true
    }
  } else {
    closedShape = true
  }

  // 如果是封闭的stroke,则需要在首尾顶点中间的位置插入2个一样的顶点,这是为了处理起点和终点处的lineJoin
  if (closedShape) {
    // 第一个点
    const fx = vertices[0]
    const fy = vertices[1]

    // 最后一个点
    const lx = vertices[vertices.length - 2]
    const ly = vertices[vertices.length - 1]

    // 如果第一个点和最后一个点的距离太近了,那么需要去掉一个点
    if (Math.abs(fx - lx) < 0.0001 && Math.abs(fy - ly) < 0.0001) {
      vertices.pop()
      vertices.pop()
    }

    // 最后一个点
    const nlx = vertices[vertices.length - 2]
    const nly = vertices[vertices.length - 1]

    // 中间的点
    const mx = (fx + nlx) / 2
    const my = (fy + nly) / 2

    vertices.unshift(mx, my)
    vertices.push(mx, my)
  }

  const batchPart = new BatchPart(lineStyle)
  geometry.batchParts.push(batchPart)

  batchPart.start(geometry.vertices.length / 2, geometry.vertexIndices.length)

  const lineVertices: number[] = []
  const lineVertexIndices: number[] = []

  // 第一个点
  const fx = vertices[0]
  const fy = vertices[1]
  // 第二个点
  const sx = vertices[2]
  const sy = vertices[3]

  const [nvx, nvy] = getNormalVector(sx - fx, sy - fy, lineWidth)

  // 处理起点的lineCap,只有非封闭stroke需要处理
  if (!closedShape) {
    if (lineCap === LINE_CAP.SQUARE) {
      // 将法向量再旋转90度
      const nnvx = nvy
      const nnvy = -nvx

      // Square相当于一个矩形
      lineVertices.push(
        fx + nvx,
        fy + nvy,
        fx + nvx + nnvx,
        fy + nvy + nnvy,
        fx - nvx + nnvx,
        fy - nvy + nnvy,
        fx - nvx,
        fy - nvy
      )

      lineVertexIndices.push(0, 1, 2, 0, 2, 3)
    }

    if (lineCap === LINE_CAP.ROUND) {
      buildRoundCorner(
        fx,
        fy,
        fx - nvx,
        fy - nvy,
        fx + nvx,
        fy + nvy,
        lineVertices,
        lineVertexIndices
      )
    }

    if (lineCap === LINE_CAP.BUTT) {
      // butt是默认值
      // 什么都不用做
    }
  }

  cursor = lineVertices.length / 2

  // 往顶点数组里塞2个顶点,以供下一次处理lineJoin使用
  lineVertices.push(fx - nvx, fy - nvy, fx + nvx, fy + nvy)

  // 处理每一个线段以及线段之间的lineJoin
  for (let i = 2; i < vertices.length - 2; i += 2) {
    // 第一个点
    const x0 = vertices[i - 2]
    const y0 = vertices[i - 1]
    // 第二个点
    const x1 = vertices[i]
    const y1 = vertices[i + 1]
    // 第三个点
    const x2 = vertices[i + 2]
    const y2 = vertices[i + 3]

    const [nvx1, nvy1] = getNormalVector(x1 - x0, y1 - y0, lineWidth)
    const [nvx2, nvy2] = getNormalVector(x2 - x1, y2 - y1, lineWidth)

    const dx0 = x1 - x0
    const dy0 = y1 - y0
    const dx1 = x2 - x1
    const dy1 = y2 - y1

    const cross = dx1 * dy0 - dx0 * dy1 // 叉积
    const dot = dx0 * dx1 + dy0 * dy1 // 点积

    // 判断两个线段的夹角是否约等于0或者180度
    if (Math.abs(cross) < 0.001 * Math.abs(dot)) {
      // 右边乘以点积,让这个判断逻辑不受线段长度的影响,从而更加准确

      /**
       * 处理线段本身
       */
      lineVertices.push(x1 + nvx1, y1 + nvy1, x1 - nvx1, y1 - nvy1)
      lineVertexIndices.push(
        0 + cursor,
        1 + cursor,
        2 + cursor,
        0 + cursor,
        2 + cursor,
        3 + cursor
      )

      cursor = lineVertices.length / 2

      if (dot > 0) {
        // 两个线段同向,当作0度处理

        // 往顶点数组里塞2个顶点,以供下一次处理lineJoin使用
        lineVertices.push(x1 - nvx1, y1 - nvy1, x1 + nvx1, y1 + nvy1)
      } else {
        // 两个线段反向,则当作180度处理
        if (lineJoin === LINE_JOIN.ROUND) {
          buildRoundCorner(
            x1,
            y1,
            x1 + nvx1,
            y1 + nvy1,
            x1 - nvx1,
            y1 - nvy1,
            lineVertices,
            lineVertexIndices
          )

          cursor = lineVertices.length / 2
        }

        if (lineJoin === LINE_JOIN.BEVEL || lineJoin === LINE_JOIN.MITER) {
          // 什么都不用做
        }

        // 往顶点数组里塞2个顶点,以供下一次处理lineJoin使用
        lineVertices.push(x1 + nvx1, y1 + nvy1, x1 - nvx1, y1 - nvy1)
      }

      continue
    }

    // 外侧的miter point
    const [ompx, ompy] = getIntersectingPoint(
      x0 + nvx1,
      y0 + nvy1,
      x1 + nvx1,
      y1 + nvy1,
      x1 + nvx2,
      y1 + nvy2,
      x2 + nvx2,
      y2 + nvy2
    )
    // 内侧的miter point
    const [impx, impy] = getIntersectingPoint(
      x0 - nvx1,
      y0 - nvy1,
      x1 - nvx1,
      y1 - nvy1,
      x1 - nvx2,
      y1 - nvy2,
      x2 - nvx2,
      y2 - nvy2
    )

    let realLineJoin = lineJoin
    if (lineJoin === LINE_JOIN.MITER) {
      // miterLength的平方
      const miterLenSq = (impx - ompx) ** 2 + (impy - ompy) ** 2
      const lineWidthSq = lineWidth ** 2

      const miterOk = miterLenSq / lineWidthSq <= miterLimit ** 2

      if (!miterOk) {
        // 超出了miterLimit则将此处的lineJoin置为'bevel'
        realLineJoin = LINE_JOIN.BEVEL
      }
    }

    const lineLen1Sq = dx0 * dx0 + dy0 * dy0
    const diagonal1Sq = lineLen1Sq + (lineWidth / 2) ** 2
    const lineLen2Sq = dx1 * dx1 + dy1 * dy1
    const diagonal2Sq = lineLen2Sq + (lineWidth / 2) ** 2

    const isLineLongEnough =
      (impx - x1) ** 2 + (impy - y1) ** 2 < Math.min(diagonal1Sq, diagonal2Sq)

    if (isLineLongEnough) {
      if (realLineJoin === LINE_JOIN.BEVEL) {
        if (cross > 0) {
          // 四边形
          lineVertices.push(ompx, ompy, x1 - nvx1, y1 - nvy1)
          lineVertexIndices.push(
            0 + cursor,
            1 + cursor,
            2 + cursor,
            0 + cursor,
            2 + cursor,
            3 + cursor
          )

          cursor = lineVertices.length / 2

          // 三角形
          lineVertices.push(
            ompx,
            ompy,
            x1 - nvx1,
            y1 - nvy1,
            x1 - nvx2,
            y1 - nvy2
          )

          lineVertexIndices.push(0 + cursor, 1 + cursor, 2 + cursor)

          cursor = lineVertices.length / 2

          // 结尾
          lineVertices.push(x1 - nvx2, y1 - nvy2, ompx, ompy)
        } else {
          // 四边形
          lineVertices.push(x1 + nvx1, y1 + nvy1, impx, impy)
          lineVertexIndices.push(
            0 + cursor,
            1 + cursor,
            2 + cursor,
            0 + cursor,
            2 + cursor,
            3 + cursor
          )

          cursor = lineVertices.length / 2

          // 三角形
          lineVertices.push(
            impx,
            impy,
            x1 + nvx1,
            y1 + nvy1,
            x1 + nvx2,
            y1 + nvy2
          )

          lineVertexIndices.push(0 + cursor, 1 + cursor, 2 + cursor)

          cursor = lineVertices.length / 2

          // 结尾
          lineVertices.push(impx, impy, x1 + nvx2, y1 + nvy2)
        }
      }

      if (realLineJoin === LINE_JOIN.MITER) {
        lineVertices.push(ompx, ompy, impx, impy)
        lineVertexIndices.push(
          0 + cursor,
          1 + cursor,
          2 + cursor,
          0 + cursor,
          2 + cursor,
          3 + cursor
        )
        cursor = lineVertices.length / 2

        lineVertices.push(impx, impy, ompx, ompy)
      }

      if (realLineJoin === LINE_JOIN.ROUND) {
        if (cross < 0) {
          // 四边形
          lineVertices.push(x1 + nvx1, y1 + nvy1, impx, impy)
          lineVertexIndices.push(
            0 + cursor,
            1 + cursor,
            2 + cursor,
            0 + cursor,
            2 + cursor,
            3 + cursor
          )

          cursor = lineVertices.length / 2

          // 三角形1
          lineVertices.push(impx, impy, x1 + nvx1, y1 + nvy1, x1, y1)
          lineVertexIndices.push(0 + cursor, 1 + cursor, 2 + cursor)

          cursor = lineVertices.length / 2

          // 三角形2
          lineVertices.push(impx, impy, x1 + nvx2, y1 + nvy2, x1, y1)
          lineVertexIndices.push(0 + cursor, 1 + cursor, 2 + cursor)

          cursor = lineVertices.length / 2

          // 扇形
          buildRoundCorner(
            x1,
            y1,
            x1 + nvx1,
            y1 + nvy1,
            x1 + nvx2,
            y1 + nvy2,
            lineVertices,
            lineVertexIndices
          )

          cursor = lineVertices.length / 2

          // 线段的结尾
          lineVertices.push(impx, impy, x1 + nvx2, y1 + nvy2)
        } else {
          // 四边形
          lineVertices.push(ompx, ompy, x1 - nvx1, y1 - nvy1)
          lineVertexIndices.push(
            0 + cursor,
            1 + cursor,
            2 + cursor,
            0 + cursor,
            2 + cursor,
            3 + cursor
          )

          cursor = lineVertices.length / 2

          // 三角形1
          lineVertices.push(ompx, ompy, x1 - nvx1, y1 - nvy1, x1, y1)
          lineVertexIndices.push(0 + cursor, 1 + cursor, 2 + cursor)

          cursor = lineVertices.length / 2

          // 三角形2
          lineVertices.push(ompx, ompy, x1 - nvx2, y1 - nvy2, x1, y1)
          lineVertexIndices.push(0 + cursor, 1 + cursor, 2 + cursor)

          cursor = lineVertices.length / 2

          // 扇形
          buildRoundCorner(
            x1,
            y1,
            x1 - nvx2,
            y1 - nvy2,
            x1 - nvx1,
            y1 - nvy1,
            lineVertices,
            lineVertexIndices
          )

          cursor = lineVertices.length / 2

          // 线段的结尾
          lineVertices.push(x1 - nvx2, y1 - nvy2, ompx, ompy)
        }
      }
    } else {
      // 线段1的整体
      lineVertices.push(x1 + nvx1, y1 + nvy1, x1 - nvx1, y1 - nvy1)
      lineVertexIndices.push(
        0 + cursor,
        1 + cursor,
        2 + cursor,
        0 + cursor,
        2 + cursor,
        3 + cursor
      )

      cursor = lineVertices.length / 2

      if (realLineJoin === LINE_JOIN.BEVEL) {
        if (cross > 0) {
          // 三角形
          lineVertices.push(x1, y1, x1 - nvx1, y1 - nvy1, x1 - nvx2, y1 - nvy2)
          lineVertexIndices.push(0 + cursor, 1 + cursor, 2 + cursor)
        } else {
          // 三角形
          lineVertices.push(x1, y1, x1 + nvx1, y1 + nvy1, x1 + nvx2, y1 + nvy2)
          lineVertexIndices.push(0 + cursor, 1 + cursor, 2 + cursor)
        }
      }

      if (realLineJoin === LINE_JOIN.MITER) {
        if (cross > 0) {
          // 2个三角形
          lineVertices.push(
            x1,
            y1,
            x1 - nvx1,
            y1 - nvy1,
            impx,
            impy,
            x1 - nvx2,
            y1 - nvy2
          )
          lineVertexIndices.push(
            0 + cursor,
            1 + cursor,
            2 + cursor,
            0 + cursor,
            2 + cursor,
            3 + cursor
          )
        } else {
          // 2个三角形
          lineVertices.push(
            x1,
            y1,
            x1 + nvx1,
            y1 + nvy1,
            ompx,
            ompy,
            x1 + nvx2,
            y1 + nvy2
          )
          lineVertexIndices.push(
            0 + cursor,
            1 + cursor,
            2 + cursor,
            0 + cursor,
            2 + cursor,
            3 + cursor
          )
        }
      }

      if (realLineJoin === LINE_JOIN.ROUND) {
        if (cross > 0) {
          // 一个扇形
          buildRoundCorner(
            x1,
            y1,
            x1 - nvx2,
            y1 - nvy2,
            x1 - nvx1,
            y1 - nvy1,
            lineVertices,
            lineVertexIndices
          )
        } else {
          // 一个扇形
          buildRoundCorner(
            x1,
            y1,
            x1 + nvx1,
            y1 + nvy1,
            x1 + nvx2,
            y1 + nvy2,
            lineVertices,
            lineVertexIndices
          )
        }
      }

      cursor = lineVertices.length / 2

      lineVertices.push(x1 - nvx2, y1 - nvy2, x1 + nvx2, y1 + nvy2)
    }
  }

  // 处理最后一个线段
  const lastX = vertices[vertices.length - 2]
  const lastY = vertices[vertices.length - 1]
  const secondLastX = vertices[vertices.length - 4]
  const secondLastY = vertices[vertices.length - 3]
  const [lastNvx, lastNvy] = getNormalVector(
    lastX - secondLastX,
    lastY - secondLastY,
    lineWidth
  )

  lineVertices.push(
    lastX + lastNvx,
    lastY + lastNvy,
    lastX - lastNvx,
    lastY - lastNvy
  )

  lineVertexIndices.push(
    0 + cursor,
    1 + cursor,
    2 + cursor,
    0 + cursor,
    2 + cursor,
    3 + cursor
  )

  cursor = lineVertices.length / 2

  // 处理终点的lineCap,只有非封闭stroke需要处理
  if (!closedShape) {
    // 最后一个点
    const lx = vertices[vertices.length - 2]
    const ly = vertices[vertices.length - 1]
    // 倒数第二个点
    const slx = vertices[vertices.length - 4]
    const sly = vertices[vertices.length - 3]

    const [nvx, nvy] = getNormalVector(lx - slx, ly - sly, lineWidth)

    if (lineCap === LINE_CAP.SQUARE) {
      // 将法向量再旋转90度
      const nnvx = -nvy
      const nnvy = nvx

      lineVertices.push(
        lx + nvx,
        ly + nvy,
        lx + nvx + nnvx,
        ly + nvy + nnvy,
        lx - nvx + nnvx,
        ly - nvy + nnvy,
        lx - nvx,
        ly - nvy
      )

      lineVertexIndices.push(
        0 + cursor,
        1 + cursor,
        2 + cursor,
        0 + cursor,
        2 + cursor,
        3 + cursor
      )
    }

    if (lineCap === LINE_CAP.ROUND) {
      buildRoundCorner(
        lx,
        ly,
        lx + nvx,
        ly + nvy,
        lx - nvx,
        ly - nvy,
        lineVertices,
        lineVertexIndices
      )
    }
  }

  geometry.vertices.concat(lineVertices)
  geometry.vertexIndices.concat(lineVertexIndices)

  batchPart.end(lineVertices.length / 2, lineVertexIndices.length)
}

6.4 一个完整的线段的剖分

代码:

typescript 复制代码
const g = new Graphics().beginFill(0x000001, 0).lineStyle({
  color: 0x000000,
  width: 100,
  cap: LINE_CAP.ROUND,
  join: LINE_JOIN.ROUND
})
g.moveTo(200, 400)
g.lineTo(500, 400)
g.lineTo(600, 100)
app.stage.addChild(g)

效果:

6.5 一条捷径

可以看到,上面对描边进行三角剖分的过程是纯手写的,其中的逻辑非常复杂,并且还有一些边界case需要处理,所以,大家可以走一条捷径:只负责将这个描边顶点化,然后把顶点交给earcut,让earcut来进行三角剖分,这样的话就能少写很多代码了。

7. 总结

在这篇文章里,讲述了如何对图形的填充和描边进行顶点化和三角剖分,到现在,任何图形我们都能使用三角形来近似了,也就是说,webGL渲染的第一步工作已经做完了。下一篇文章将会讲述如何使用我们得到的这些顶点来让webGL绘制出我们想要的图形。

谢谢观看🙏,如果觉得本文还不错,就点个赞吧👍。

相关推荐
普兰店拉马努金2 分钟前
【Canvas与色彩】十二等分多彩隔断圆环
canvas·圆环·隔断
一颗花生米。3 小时前
深入理解JavaScript 的原型继承
java·开发语言·javascript·原型模式
学习使我快乐013 小时前
JS进阶 3——深入面向对象、原型
开发语言·前端·javascript
bobostudio19953 小时前
TypeScript 设计模式之【策略模式】
前端·javascript·设计模式·typescript·策略模式
勿语&4 小时前
Element-UI Plus 暗黑主题切换及自定义主题色
开发语言·javascript·ui
黄尚圈圈4 小时前
Vue 中引入 ECharts 的详细步骤与示例
前端·vue.js·echarts
浮华似水5 小时前
简洁之道 - React Hook Form
前端
正小安7 小时前
如何在微信小程序中实现分包加载和预下载
前端·微信小程序·小程序
_.Switch9 小时前
Python Web 应用中的 API 网关集成与优化
开发语言·前端·后端·python·架构·log4j