用 WebGL 实现一个齿轮动画
// 每日前端夜话 第395篇
// 正文共:3700 字
// 预计阅读时间:10 分钟
本文继续 中的内容,上一篇文章中我讲述了 WebGL是什么以及它是如何工作的,包括:shader、program、缓冲区、如何将数据从 CPU 链接到 GPU 和最终怎样渲染三角形。
在本文中,我们将研究如何渲染更复杂的结构以及怎样使其运动。所以,我们将实现三个动态齿轮:
识别形状
要绘制的齿轮由圆组成,不过这些圆需要一些变化:带齿的圆、带有彩色边框的圆和填充有颜色的圆。
我们可以通过绘制圆来绘制这些齿轮,但是在 WebGL 中只能光栅化三角形,点和线...所以这些圆之间的区别是什么,怎样才能做到呢?
带边框的圆
我们将使用多个点来绘制带边框的圆,:
填充颜色的圆
我们将使用多个三角形绘制一个填充颜色的圆,:
所以需要用退化三角形(Triangle strip) 模式:
❝退化三角形(Triangle strip) 是三角形网格中一系列相连的三角形,共享顶点,从而可以更有效地利用计算机图形的内存。它们比不带索引的三角列表更有效,但效率一般不如带索引的三角列表稳定。之所以使用退化三角形,主要原因是能够减少创建一系列三角形所需的数据量。存储在内存中的顶点数量从 3N 减少到了 N + 2,其中 N 是要绘制的三角形数量。这样可以减少磁盘空间的使用,并能够使它们更快地加载到内存中。
❞
带齿轮的圆
我们还会使用三角形处理齿轮。这次不用“strip”模式,而是要绘制从圆周中心辐射开的三角形。
在构建齿轮时,还要在内部创建另外一个充满颜色的圆,以便使齿轮从圆本身突出出来。
识别要绘制的数据
这3种图形的共同点是可以从 2 个变量中计算出它们的坐标:
-
圆心( x 和 y) -
半径
在上一篇文章中我们知道了,webGL 中的坐标范围是从 -1 到 1。先让找到每个齿轮的中心及其半径:
此外还有一些特定数字的可选变量,例如:
-
齿数 -
笔触颜色(边框的颜色)* -
填充色 -
子级*(更多具有相同数据结构的齿轮)* -
旋转方向*(仅对父级有效)*
最后在 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**[1]的线性代数视频课程,该视频很好地说明了这一点。
总而言之,如果将位置乘以任何矩阵,都将会得到一个转换。我们必须将每个齿轮位置乘以旋转矩阵。需要在其前面添加每个“转换”。如果要旋转,我们将执行 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 // 这不是我们想要的
我们已经使齿轮旋转了,但是旋转轴却是画布的中心,这是错误的。我们希望他们围绕自己的中心旋转。
为了解决这个问题,首先把使用名为 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_translation
、u_rotation
和 u_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() // 下一节解释这个函数
getGLContext
、getShader
和 getProgram
完成了我们在中的操作。我把它们放在这里:
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_translation
、u_rotation
和 u_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[2] 实现的。
https://github.com/aralroca/webgl-gears
总结
我们学到如何用三角形和点生成更复杂的图形,并实现了机遇矩阵乘法的运动。
线(line) 是一种我们尚未见过的绘图模式。那是因为可以用它制作的线很细,并不适合画齿轮的齿。你不能轻易的更改线条的粗细,而要做到这一点,必须制作一个矩形(2个三角形)。这些线的灵活性很小,大多数图形都是用三角形绘制的。不过你应该能够轻松使用给定 2 个 坐标的 gl.LINES
。
本文是 WebGL 系列的第二部分。在本系列的下一篇文章中,我们将学到纹理、图像处理、帧缓冲区、3d对象等。
Reference
3blue1brown: https://www.youtube.com/watch?v=fNk_zzaMoSs&list=PLZHQObOWTQDPD3MizzM2xVFitgF8hE_ab
[2]Preact: https://preactjs.com/
|学习视频|源码资源|