用 WebGL 作一个齿轮动画

原文:Aral Roca

翻译:疯狂的技术宅前端

https://aralroca.com/blog/how...react

未经容许严禁转载git

本文继续 “WebGL 的第一步” 中的内容,上一篇文章中我讲述了 WebGL是什么以及它是如何工做的,包括:shader、program、缓冲区、如何将数据从 CPU 连接到 GPU 和最终怎样渲染三角形。程序员

在本文中,咱们将研究如何渲染更复杂的结构以及怎样使其运动。因此,咱们将实现三个动态齿轮github

本文中产生的齿轮

识别形状

要绘制的齿轮由组成,不过这些圆须要一些变化:带齿的圆、带有彩色边框的圆和填充有颜色的圆。web

image.png

咱们能够经过绘制圆来绘制这些齿轮,可是在 WebGL 中只能光栅化三角形,点和线...因此这些圆之间的区别是什么,怎样才能作到呢?面试

带边框的圆

咱们将使用多个来绘制带边框的圆,:canvas

用点绘制圆

填充颜色的圆

咱们将使用多个三角形绘制一个填充颜色的圆,:segmentfault

用退化三角形绘制的实心圆

因此须要用退化三角形(Triangle strip)绘制模式:数组

退化三角形(Triangle strip) 是三角形网格中一系列相连的三角形,共享顶点,从而能够更有效地利用计算机图形的内存。它们比不带索引的三角列表更有效,但效率通常不如带索引的三角列表稳定。之因此使用退化三角形,主要缘由是可以减小建立一系列三角形所需的数据量。存储在内存中的顶点数量从 3N 减小到了 N + 2,其中 N 是要绘制的三角形数量。这样能够减小磁盘空间的使用,并可以使它们更快地加载到内存中。

带齿轮的圆

咱们还会使用三角形处理齿轮。此次不用“strip”模式,而是要绘制从圆周中心辐射开的三角形。

齿轮齿为三角形

在构建齿轮时,还要在内部建立另一个充满颜色的圆,以便使齿轮从圆自己突出出来。

识别要绘制的数据

这3种图形的共同点是能够从 2 个变量中计算出它们的坐标:

  1. 圆心(xy
  2. 半径

在上一篇文章中咱们知道了,webGL 中的坐标范围是从 -1 到 1。先让找到每一个齿轮的中心及其半径:

image.png

此外还有一些特定数字的可选变量,例如:

  • 齿数
  • 笔触颜色(边框的颜色)*
  • 填充色
  • 子级(更多具备相同数据结构的齿轮)
  • 旋转方向(仅对父级有效)

最后在 JavaScript 中,咱们将获得一个包含三个齿轮及其全部零件的数据的数组:

const x1 = 0.1
const y1 = -0.2

const x2 = -0.42
const y2 = 0.41

const x3 = 0.56
const y3 = 0.28

export const gears = [
  {
    center: [x1, y1],
    direction: 'counterclockwise',
    numberOfTeeth: 20,
    radius: 0.45,
    fillColor: [0.878, 0.878, 0.878],
    children: [
      {
        center: [x1, y1],
        radius: 0.4,
        strokeColor: [0.682, 0.682, 0.682],
      },
      {
        center: [x1, y1],
        radius: 0.07,
        fillColor: [0.741, 0.741, 0.741],
        strokeColor: [0.682, 0.682, 0.682],
      },
      {
        center: [x1 - 0.23, y1],
        radius: 0.12,
        fillColor: [1, 1, 1],
        strokeColor: [0.682, 0.682, 0.682],
      },
      {
        center: [x1, y1 - 0.23],
        radius: 0.12,
        fillColor: [1, 1, 1],
        strokeColor: [0.682, 0.682, 0.682],
      },
      {
        center: [x1 + 0.23, y1],
        radius: 0.12,
        fillColor: [1, 1, 1],
        strokeColor: [0.682, 0.682, 0.682],
      },
      {
        center: [x1, y1 + 0.23],
        radius: 0.12,
        fillColor: [1, 1, 1],
        strokeColor: [0.682, 0.682, 0.682],
      },
    ],
  },
  {
    center: [x2, y2],
    direction: 'clockwise',
    numberOfTeeth: 12,
    radius: 0.3,
    fillColor: [0.741, 0.741, 0.741],
    children: [
      {
        center: [x2, y2],
        radius: 0.25,
        strokeColor: [0.682, 0.682, 0.682],
      },
      {
        center: [x2, y2],
        radius: 0.1,
        fillColor: [0.682, 0.682, 0.682],
        strokeColor: [0.6, 0.6, 0.6],
      },
    ],
  },
  {
    center: [x3, y3],
    direction: 'clockwise',
    numberOfTeeth: 6,
    radius: 0.15,
    fillColor: [0.741, 0.741, 0.741],
    children: [
      {
        center: [x3, y3],
        radius: 0.1,
        strokeColor: [0.682, 0.682, 0.682],
      },
      {
        center: [x3, y3],
        radius: 0.02,
        fillColor: [0.682, 0.682, 0.682],
        strokeColor: [0.6, 0.6, 0.6],
      },
    ],
  },
]

对于颜色,有一点须要注意:取值范围是从 0 到 1,而不是从 0 到 255,或从 0 到 F,这些是咱们在 CSS 中惯用的。例如,[0.682,0.682,0.682] 等同于 rgb(174,174,174)#AEAEAE

怎样实现旋转

在开始实现以前须要知道如何实现每一个齿轮的旋转。

为了了解旋转和其余线性变换,我强烈建议你看看3blue1brown的线性代数视频课程,该视频很好地说明了这一点:

(视频4)

总而言之,若是将位置乘以任何矩阵,都将会获得一个转换。咱们必须将每一个齿轮位置乘以旋转矩阵。须要在其前面添加每一个“转换”。若是要旋转,咱们将执行 rotation * positions 而不是 positions * rotation

能够经过知道弧度角来建立旋转矩阵:

function rotation(angleInRadians = 0) {
  const c = Math.cos(angleInRadians)
  const s = Math.sin(angleInRadians)

  return [
    c, -s, 0, 
    s, c, 0, 
    0, 0, 1
  ]
}

这样就能够经过将每一个齿轮的位置与其各自的旋转矩阵相乘来使每一个齿轮不一样地旋转。为了产生真实的旋转效果,在每一个帧中必须稍微增长角度,直到完成完整的旋转,而且角度转回到0。

可是仅仅将位置与该矩阵相乘是不够的。若是这样作,你将会看到下面这样的结果:

rotationMatrix * positionMatrix // 这不是咱们想要的

在canvas上旋转的齿轮(不是咱们想要的)

咱们已经使齿轮旋转了,可是旋转轴倒是画布的中心,这是错误的。咱们但愿他们围绕本身的中心旋转。

为了解决这个问题,首先把使用名为 translate 的转换将齿轮移动到画布的中心。而后,再把应用正确的旋转(该轴将再次成为画布的中心,但在这种状况下,它也是齿轮的中心),最后把齿轮移回其原始位置(再次使用 translate)。

转换矩阵定义以下:

function translation(tx, ty) {
  return [
    1, 0, 0, 
    0, 1, 0, 
    tx, ty, 1
  ]
}

咱们将建立两个转换矩阵:translation(centerX, centerY)translation(-centerX, -centerY)。它们的中心必须是每一个齿轮的中心。

因此要执行下面的矩阵乘法:

// 如今它们会围绕本身的轴心旋转
translationMatrix * rotationMatrix * translationToOriginMatrix * positionMatrix

正确的旋转方式

你可能想知道如何使每一个齿轮按照本身的速度旋转。

有一个简单的公式能够根据齿数计算速度:

(Speed A * Number of teeth A) = (Speed B * Number of teeth B)

这样,在每一个框架中,咱们能够为每一个齿轮增长一个不一样的角度步长,而且每一个齿轮都以他们应有的速度旋转。

实现

你看到这里应该知道:

  • 应该画什么,怎样画。
  • 咱们有每一个齿轮及其零件的坐标。
  • 怎样旋转每一个齿轮。

下面看看如何用 JavaScript 和 GLSL 实现。

用着色器初始化程序

编写 vertex shader 来计算顶点的位置:

const vertexShader = `#version 300 es
precision mediump float;
in vec2 position;
uniform mat3 u_rotation;
uniform mat3 u_translation;
uniform mat3 u_moveOrigin;

void main () {
  vec2 movedPosition = (u_translation * u_rotation * u_moveOrigin * vec3(position, 1)).xy;
  gl_Position = vec4(movedPosition, 0.0, 1.0);
  gl_PointSize = 1.0;
}
`

与上一篇文章中使用的顶点着色器不一样,咱们将传递 u_translationu_rotationu_moveOrigin 矩阵,所以 gl_Position 是四个矩阵的乘积(还有 position) 。像上一节所所说的那样,经过这种方式产生旋转。另外,咱们将使用 gl_PointSize 定义所绘制的每一个点的大小(这对于带有边框的圆颇有用)。

注意:咱们能够直接用 JavaScript 在 CPU 上执行矩阵乘法的操做,而且已经在这里传递了最终矩阵,但实际上 GPU 才是专门为矩阵运算而设计的,由于这样作的性能要好得多。另外因为没法直接对数组进行乘法运算,因此在 JavaScript 中须要一个辅助函数来进行乘法运算。

下面编写片断着色器来计算与每一个位置对应的像素颜色:

const fragmentShader = `#version 300 es
precision mediump float;
out vec4 color;
uniform vec3 inputColor;

void main () {
   color = vec4(inputColor, 1.0);
}
`

给出用 JavaScript 在 CPU 中所定义的颜色,并将其传递给 GPU 来对图形进行着色。

如今可使用着色器建立程序,经过添加线条来获取咱们在顶点着色器中定义的统一位置。这样稍后在运行脚本时,能够将每一个矩阵发送到每一帧的每一个统一位置。

const gl = getGLContext(canvas)
const vs = getShader(gl, vertexShader, gl.VERTEX_SHADER)
const fs = getShader(gl, fragmentShader, gl.FRAGMENT_SHADER)
const program = getProgram(gl, vs, fs)
const rotationLocation = gl.getUniformLocation(program, 'u_rotation')
const translationLocation = gl.getUniformLocation(program, 'u_translation')
const moveOriginLocation = gl.getUniformLocation(program, 'u_moveOrigin')

run() // 下一节解释这个函数

getGLContextgetShadergetProgram 完成了咱们在上一篇文章中的操做。我把它们放在这里:

function getGLContext(canvas, bgColor) {
  const gl = canvas.getContext('webgl2')
  const defaultBgColor = [1, 1, 1, 1]

  gl.clearColor(...(bgColor || defaultBgColor))
  gl.clear(gl.DEPTH_BUFFER_BIT | gl.COLOR_BUFFER_BIT)

  return gl
}

function getShader(gl, shaderSource, shaderType) {
  const shader = gl.createShader(shaderType)

  gl.shaderSource(shader, shaderSource)
  gl.compileShader(shader)

  if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
    console.error(gl.getShaderInfoLog(shader))
  }

  return shader
}

function getProgram(gl, vs, fs) {
  const program = gl.createProgram()

  gl.attachShader(program, vs)
  gl.attachShader(program, fs)
  gl.linkProgram(program)
  gl.useProgram(program)

  if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
    console.error(gl.getProgramInfoLog(program))
  }

  return program
}

绘制每帧 + 计算旋转角度

上一节代码中的 run 函数负责在每一帧中以不一样角度绘制齿轮。

// 1 个齿的齿轮步长,
// 齿数更多的步长将用如下公式计算:
// realRotationStep = rotationStep / numberOfTeeth
const rotationStep = 0.2

// 角度都初始化为0
const angles = Array.from({ length: gears.length }).map((v) => 0)

function run() {
  // 为每一个齿轮计算在该帧的角度
  gears.forEach((gear, index) => {
    const direction = gear.direction === 'clockwise' ? 1 : -1
    const step = direction * (rotationStep / gear.numberOfTeeth)

    angles[index] = (angles[index] + step) % 360
  })

  drawGears() // 下一节解释这个函数

  // Render next frame
  window.requestAnimationFrame(run)
}

根据齿轮组数组中的数据,能够知道“齿”的数量以及每一个齿轮的旋转方向。这样就能够计算每帧中每一个齿轮的角度。保存新的计算角度后调用函数 drawGears 来正确的角度绘制每一个齿轮。而后递归地再次调用 run 函数(与window.requestAnimationFrame 包装在一块儿,确保仅在下一个动画周期中再次调用它)。

你可能想知道为何不隐含地告诉每一帧以前清除canvas。这是由于 WebGL 在绘制时会自动执行。若是它检测到咱们更改了输入变量,则默认状况下会清除以前的缓冲区。若是出于某种缘由不是当前这种状况咱们不但愿清理画布,那么应该使用附加参数 const gl = canvas.getContext('webgl',{prepareDrawingBuffer: true});

绘制齿轮

对于每帧中的每一个齿轮,先把旋转所需的矩阵 u_translationu_rotationu_moveOrigin 传递给GPU,而后开始绘制齿轮的每一个部分:

function drawGears() {
  gears.forEach((gear, index) => {
    const [centerX, centerY] = gear.center

    // u_translation
    gl.uniformMatrix3fv(
      translationLocation,
      false,
      translation(centerX, centerY)
    )

    // u_rotation
    gl.uniformMatrix3fv(rotationLocation, false, rotation(angles[index]))

    // u_moveOrigin
    gl.uniformMatrix3fv(
      moveOriginLocation,
      false,
      translation(-centerX, -centerY)
    )

    // 渲染齿轮
    renderGearPiece(gear)
    if (gear.children) gear.children.forEach(renderGearPiece)
  })
}

用相同的函数绘制齿轮的每一个部分:

function renderGearPiece({
  center,
  radius,
  fillColor,
  strokeColor,
  numberOfTeeth,
}) {
  const { TRIANGLE_STRIP, POINTS, TRIANGLES } = gl
  const coords = getCoords(gl, center, radius)

  if (fillColor) drawShape(coords, fillColor, TRIANGLE_STRIP)
  if (strokeColor) drawShape(coords, strokeColor, POINTS)
  if (numberOfTeeth) {
    drawShape(
      getCoords(gl, center, radius, numberOfTeeth),
      fillColor,
      TRIANGLES
    )
  }
}
  • 若是是带边界的圆 --> 使用 POINTS
  • 若是是彩色圆 --> 使用 TRIANGLE_STRIP
  • 若是是一个有齿的圆 --> 使用 TRIANGLES

经过使用各类 if,能够建立一个填充有一种颜色但边框是另外一种颜色的圆,或者建立一个填充有颜色和齿的圆。这意味着更大的灵活性。

实心圆和带有边界的圆的坐标,即便一个是由三角形组成而另外一个是由点制成,也是彻底相同的。一个有着不一样坐标的带齿的圆,也能够用相同的代码来获取坐标:

export default function getCoords(gl, center, radiusX, teeth = 0) {
  const toothSize = teeth ? 0.05 : 0
  const step = teeth ? 360 / (teeth * 3) : 1
  const [centerX, centerY] = center
  const positions = []
  const radiusY = (radiusX / gl.canvas.height) * gl.canvas.width

  for (let i = 0; i <= 360; i += step) {
    positions.push(
      centerX,
      centerY,
      centerX + (radiusX + toothSize) * Math.cos(2 * Math.PI * (i / 360)),
      centerY + (radiusY + toothSize) * Math.sin(2 * Math.PI * (i / 360))
    )
  }

  return positions
}

drawShape 的代码与上一篇文章中看到的代码相同:它将坐标和颜色传递给 GPU,而后调用 drawArrays 函数来指示模式。

function drawShape(coords, color, drawingMode) {
  const data = new Float32Array(coords)
  const buffer = createAndBindBuffer(gl, gl.ARRAY_BUFFER, gl.STATIC_DRAW, data)

  gl.useProgram(program)
  linkGPUAndCPU(gl, { program, buffer, gpuVariable: 'position' })

  const inputColor = gl.getUniformLocation(program, 'inputColor')
  gl.uniform3fv(inputColor, color)
  gl.drawArrays(drawingMode, 0, coords.length / 2)
}

完成~

全部代码

本文的全部代码在 GitHub 上能够找到,用 Preact 实现的。

总结

咱们学到如何用三角形和点生成更复杂的图形,并实现了基于矩阵乘法的运动。

线(line)是一种咱们还没有见过的绘图模式。那是由于能够用它制做的线很细,并不适合画齿轮的齿。你不能轻易的更改线条的粗细,而要作到这一点,必须制做一个矩形(2个三角形)。这些线的灵活性很小,大多数图形都是用三角形绘制的。不过你应该可以轻松使用给定 2 个 坐标的 gl.LINES

本文是 WebGL 系列的第二部分。在本系列的下一篇文章中,咱们将学到纹理、图像处理、帧缓冲区、3d对象等。

173382ede7319973.gif


本文首发微信公众号:前端先锋

欢迎扫描二维码关注公众号,天天都给你推送新鲜的前端技术文章

欢迎扫描二维码关注公众号,天天都给你推送新鲜的前端技术文章

欢迎继续阅读本专栏其它高赞文章:


相关文章
相关标签/搜索