上篇讲了个如何使用gpu.js这个库来进行简单的gpu计算 虽然简单易用 但是本身的局限也很多 目前这个库也不是非常完善 有待改进 那咱就从原理开始 来自己搞一个吧 当然 并不是指实现一个这个的通用的库 而是使用相关原理 完成一个是用GPU计算的demo 当然还是矩阵的乘法
前端使用GPU的能力是通过webgl实现的 更加广泛的理解的可以认为是通过canvas来说实现的 canvas估计对大多数前端来说并不陌生 canvas有许多个像素组成 每个像素的颜色可以有RGBA
四个维度表示 每个维度范围为0-255 既8位 把RGBA表示成数值的话 那每个像素可以存32位 这就是前端使用gpu计算最为核心的一点 每个像素可以存储一个32位的值, 刚刚好就是一个int
或者uint
0.基本WebGL绘制
首先从最简单的绘制一个图像开始 webgl绘图的流程 最简单的就这样
其中两个vertex shader
和fragment shader
为两个GLSL
代码片段 分别处理坐标数据和颜色数据 vertex shader
和fragment shader
的执行是以像素为单位
canvas开始绘制的时候 vertex shader
中得到 每个需要绘制的像素的坐标 视需要可以对坐标进行各种转换 最终得到一个最终位置 这个过程中可以将数据作为输出传入fragment shader
参与下一步的计算
fragment shader
接受各种输入 最终输出一个RGBA
颜色数据作为该像素点的颜色值
当所有像素都绘制完成之后 画布绘制完成
0.0 js中的流程就比较简单了
- 创建
webgl program
- 初始化两个
shader
- 传入各个顶点坐标
- 开始绘制
因为咱们主要是计算 所以对坐标相关的数据可以不用太多关注 咱们直接画一个铺满画布的矩形就可以了
1 | // 加载资源 |
需要注意的一点vertex shader
中得到的坐标是以canvas中心为(0,0)
水平向右为x轴正方向 垂直向上为y轴正方向 两轴的取值范围为[-1, 1]
所以上面js代码中传入的顶点坐标范围为[-1, 1]
的浮点数
另外OpenGL中绘制面都是以三角形为单位的 webgl中也不例外 提供了一个绘制连续三角形的方式 一个矩形是两个三角形 所以传入四个顶点就可以了 当然也可以传入六个顶点 分别绘制两个三角形
顶点的传入实际上是传入一个数组 然后vertexAttribPointer()
方法指定各个顶点如何使用这个坐标数组 可以认为是8个一维坐标 也可以认为是2个二维坐标 或者是2个四维坐标 所以上述的例子实际是传入了4个2维坐标
接下来就是两个shader中的流程 目前大部分浏览器已经支持WebGL 2.0标准 对应OpenGL ES 3.0
所以shader中的语法需要遵循相关语法
具体的版本可以使用gl.getParameter(gl.SHADING_LANGUAGE_VERSION)
获取
0.1 首先vertex shader
:
1 |
|
具体的语法啊变量类型啊什么的可以看官方的文档
只做一点说明 in
将变量标记输入 out
将变量标记输出 在webgl 1.0
中 attribute
表示输入 所以在js获取变量地址的时候使用了getAttribLocation
函数 其中的Attrib
即是这个意思 但是在webgl 2.0
这个声明被弃用 使用in
来代替
out
标记的变量的值将作为fragment shader
的输入
gl_Position
为内部变量 作为最终的坐标地址 实际中还有很多其他内置变量 就不举例了
上述的代码将以画布中心为原点的坐标系转化为以左下角为原点的坐标系 并将新的坐标系中的坐标传给下一步 后续会解释为什么要做这样一个坐标变换
0.2 然后就是fragment shader
:
1 |
|
同样 fragment shader
中也有in
和out
关键字
其中in
对应vertex shader
中的 out
变量类型以及变量名必须一致
out
为一个vec4
类型 存放最终的RGBA
结果 每个值的范围为[0, 1]
上述的代码也很简单 颜色固定为红色 但是透明度按照像素到原点的距离递增 距离越远 透明度越高
最终画出的效果是这样的
1.输入
上面已经讲了一个坐标的输入 但是计算相关的参数需要其他的方式传入 需要一点提醒 由于js中的所有数值都是浮点型 所以js和webgl进行数据传输的时候 全都必须使用类型数组 并且相当多函数只能接受某种特定的类型数组
1.0 粗暴的替换
因为两个shader都是js获取到的资源 所以在载入webgl之前可以对内容进行直接修改
一般来说 shader中要获取canvas实际的大小相当不便 所以 可以直接用这个办法将画布大小传入
js中:
1 | fshaderCode = fshaderCode.replace(/CANVAS_SIZE/g, canvas.width); |
shader中:
1 | const int U_LENGTH = CANVAS_SIZE; |
这样可以直接在其他地方获取画布大小了 不过这个方法得保证不会替换错了
最重要的是 这个办法对传入数据的格式非常有限
1.1 使用 uniform 关键字
这个方法就比较强大了 不仅一般的int/float
还可以传入 向量 数组 矩阵等各种类型
而且两个shader可以共享同一份数据
1 | function getUniformLoc(name) { |
shader中:
1 | uniform float i_matrixA[4]; |
uniform()
是个一系列的方法 传入不同类型的时候 使用了不同的函数比如上面的uniform1fv
以及后面的uniform1i
详细了解还是得看文档
这个方法等好处就是支持所有类型 但是也有一个问题 不过这个问题并不算是uniform
的问题 而是WebGL本身的局限:
- 数组长度受限, 可以使用
gl.getParameter(gl.MAX_FRAGMENT_UNIFORM_VECTORS)
或者gl.getParameter(gl.MAX_VERTEX_UNIFORM_VECTORS)
获取数组长度上限 本人实测值为1024 OpenGL ES 3.0
不支持多维数组, 这个问题将在下个版本中得到支持, 当前情况还是无解
当然还有第三种方法解决大量数据传入的问题
1.2 使用Texture 纹理
纹理就是另外的图案 这个就不多做解释了 说白了就是另外一副图 因为图都是由像素构成的 所以可以用纹理来传入大量的数据
1 | function initTexture(index, tSampler, pixels) { |
纹理的定义有点复杂 纹理的大小非常苛刻 只能是2^n * 2^n
的大小 但是数据不可能是固定的 所以 这里有个纹理进行伸缩的过程
使用设置gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR)
来设置伸缩方式 当然 实际上这个对我们这个计算没有影响 因为我们全程按百分比取值
除了缩放 还有要定义未定义点的颜色规则 比如3 * 3
的图 1/6, 1/2, 5/6
这三个位置的点和传入值完全一样这个没有问题 但是其他位置默认是渐变
可以使用gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST)
来设置不使用渐变 即各个色块都是三等分 关于两种效果 下面有例子可以看
另外参数传入可以选择多种方式 直接用<img>
标签也可以 或者直接传入像素值也可以 具体方式可以查看texImage2D
文档
当然传入透明的值也是可以的 绘制到画布上的话 真的是透明的 相当神奇
但如果是像素值传入 也可以有多种格式 本例子中将RGBA拆开成四个值分别传入 为了方便起见 可以直接使用类型数组直接将32位转成8位 但是这样的转化方式可能会引起顺序不一致 比如[0x01020304]
会被拆成[0x04, 0x03, 0x02, 0x01]
具体相关内容可以参考类型数组
最后将纹理的索引绑定到纹理变量上 注意到 下面sampler2D
类型其实也是int
这种类型被称为Opaque Types
https://www.khronos.org/opengl/wiki/Data_Type_(GLSL)#Opaque_types 注意下就可以
shader 中:
1 | uniform sampler2D samplerA; |
texture()
为内置函数 用以获取某个纹理在某点的颜色
为了保持输入的时候rgba顺序一致 在获取到纹理中某个值的时候需要重新调整顺序
关于纹理的坐标系 和canvas的是不一样的 是以左下角为原点 水平向右为x正方向 垂直向上为y轴正方向 所以前面把canvas坐标进行转化也是为了和纹理的坐标系一致
另外 像素写入的顺序 也是是从左下开始 先向右写入一行 再依次向上写入每一行
然后直接将纹理的数据1:1对绘制到画布上的效果
默认使用渐变来获取各个颜色 很明显有9个点是渐变的中心 就是上面传入的那九个值了
设置了gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST)
后九大块方块
对于计算中 后者才是我们想要的效果 不然取值还取到不认识的值 计算要崩啊
使用Texture解决了要传入大量数据的问题 但是使用比较复杂 而且数据传输也是相当地耗时 所以还是期待多维数组Arrays of Arrays 能早一天在浏览器上支持
2. 输出
输出的方式单一 直接将值赋到fragmengt
的out
声明的变量上就可以将对应的值绘制到画布上 接着可以使用gl.drawArrays()
方法来读取各个像素上的点 和纹理的输入一样 读取像素的方法也有很多参数和重载 为了方便 咱们使用下面这种直接读取RGBA这四个维度的值
1 | gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4); |
注意 readPixels
方法必须和drawArrays
方法在同一个执行队列中同步执行 否则会无法读取到数据
同上面输入的理 这里使用了Uint32Array
和Uint8Array
进行数据转化 ArrayBuffer
的长度即为画布的像素数量乘上4 因此在fragment
中输出的时候需要反转四个维度
读取的顺序和纹理写入的顺一致 都是从左下开始 沿x正方向读取一行 再向y方向读取各行 最后合并成一个完整的数组 如果输入输出和这个顺序有关的话 需要注意一下
3. 矩阵乘法实验
好了 搞了这么多 已经吧基本的输入输出搞定了 咱来开始试一下矩阵相乘吧
不多说了 直接上代码
0. 先是一个基本类
包含了输入输出等基本方法 以及会用到的其他方法 基本上前面都有介绍
1 | class GPUComputing { |
1. 然后写一个基本的 vertex shader
v_shader.c
为啥要用.c
做扩展名呢 当然是因为方便代码高亮啊
这个shader和前面那个一模一样 对画布的坐标进行了一个转化
1 |
|
2. 使用uniform
的矩阵相乘
1 | class MatrixUniform extends GPUComputing { |
f_matrix_uniform.c
:
1 |
|
数组传参是挺难看
3. 使用Texture
的矩阵相乘
1 | class MatrixTexture extends GPUComputing { |
f_matrix_texture.c
:
1 |
|
里面有个U_TEXTURE_POS_FIX
常量 用来修正texture取值的时候的位置 以免取到像素边界上造成不必要麻烦
4. 然后咱们开始写个测试例子
先定义矩阵生成的函数 和前面那篇博客差不太多 只是把数据改用了Uint32Array
来存放
1 | function createMatrix(dims, fn) { |
然后定义一个执行函数
1 | async function matrixJob(dimensions) { |
4. 结果分析
数据正确上没啥问题 不过执行时间上很明显是直接计算来的快 uniform
传参比texture
略慢一点点
不过矩阵太小了 看不出其他的 所以咱们和前面一样 使用多组数据进行对比 因为受数组长度的限制 所以之后的计算uniform
方式就不参与进来比了
同样来一个多组的数据 把不必要的log先注释掉
这个是一台设备
很明显 纯WebGL
计算比前面使用的gup.js
耗时少20% 但是 但是CPU计算在矩阵规模变大之后也有很大的下降 不清楚具体是啥原因造成的 应该是类型数组本身的性能有关吧
再看看那个配置GTX 1060 3G
的电脑
这个就相当厉害了 比gpu.js
性能高了相当多 特别是在维度低的时候 即使是大小为2048 执行时间也减少了60+%
性能比原生数组CPU计算高了200倍 比类型数组CPU计算高了300倍
但是 前面也说了 使用GPU计算最大的耗时在数据传输 那如果数据传输不算 真正计算有多快呢
也是 咱给几个上面的关键函数分别加个计时 init()
和render()
和read()
分别计时
这个是低配的电脑
这个是1060的电脑
。。。。 我觉得这个已经没法玩了啊
输入 计算 输出 三者的耗时简直 尤其是计算 因为js计时器在毫秒级别是不准确的 所以几乎可以忽略。。。 CPU和GPU差距有这么大吗 按照纯计算的时间 就算0.1毫秒计 i5和1060-O3G差的不止百万吧
两张显卡之间对比
输入的耗时差别不大 最大也就两倍差距 基本可以认为是一样的
但是读取耗时 1060比我渣核显低了最高有10倍
两个的计算时长都超过了js的计时精度所以没办法通过这样比较 只能说 不要用这种方法来比显卡计算性能
代码包含了上面所有的代码 示例因为考虑到大量的计算会造成浏览器卡死 所以只保留了三个示例 一个是按坐标距离设置透明度 一个是将九像素纹理绘制到画布上 还有一个是3维度的矩阵的乘法的三种实现 以及分别的计时和结果