WebGl实现音频可视化

WebAudio

在查阅MDN的Api的时候,看到是这么介绍:

Web Audio API使用户可以在音频上下文(AudioContext)中进行音频操作,具有模块化路由的特点。在音频节点上操作进行基础的音频, 它们连接在一起构成音频路由图。

所以接下来我试着去test, 尝试获取一首歌曲的音频数据。

获取步骤

  1. 新建一个audio上下文实例 audioCtx。
  2. 通过audio上下文创建音频源,这里有两种采样实现方式:
    2.1 createMediaElementSource 通过audio对象获取音频文件引入 - source
    2.2 createMediaStreamSource 通过stream流文件引入
  3. 再通过audio上下文创建一个分析器节点( analyserNode )它提供了实时时间频率和时间域的切点,这些数据构成了数据可视化的重要参数
  4. 接下来把分析器连接到音频源 source.connect(analyserNode)
  5. 最后把音频源连接至设备声卡 source.connect(destination)

简易流程如图:

alt text

捕获音频数据

首先设置分析器快速傅里叶变换来捕获音频数据 其决定了频谱密度。
而frequencyBinCount决定了数据链的长度,这个属性为只读属性,值默认为fftSize的一半。
这样我们可以构建出一个长度为fftSize * 0.5的32位浮点型的高速类型数组。

1
2
analyserNode.fftSize = 256;
var dataArray = new Float32Array(analyserNode.frequencyBinCount);

定义好了数组,就可以通过 getFloatFrequencyData 这个api来获取即时的音频数据。(当然还有其他获取不同类型数据的api)
此方法的调用通常定义在帧刷新方法中,做实时数据的变化监控和可视化渲染。
例如:

1
2
3
4
5
function draw() {
requestAnimationFrame(draw);
// 获取实时音频数据
analyser.getFloatFrequencyData(dataArray);
}

WebGL

为啥要用WebGL?

跳脱大数组图像渲染的内存消耗瓶颈

在1209年的今天,基本上主流浏览器都可以支持Webgl, 我们赶紧把吃力的遍历渲染的工作交给GPU的专业图像处理器来干。救救可怜的浏览器占用吧!
简单来说就是,使用glsl语言构建片元和顶点着色器这些GPU可以直接运行的图形命令,并通过webgl处理可在浏览器中运行的代码。

pixi.js中的Filter性能

在pixi v4.8.3版本的Filter中定义了fragShader(片元着色器)程序,却在stats插件监控发现帧率巨低。平均只有20多fps。
这个问题同时也存在基于pixi渲染的phaser游戏框架中,官方提供的一些filter示例也有不同程度的卡顿,希望在pixi v5的版本里的shader能得到优化。

初始化WebGL

在这里就不详细介绍概念了,首先先构建webGl的上下文并传递着色器程序。

  1. 定义一个canvas对象,尺寸设置为全屏。
1
2
3
const scene = document.getElementById('scene');
scene.width = document.documentElement.clientWidth
scene.height = document.documentElement.clientHeight;
  1. 获取webgl上下文
1
gl = scene.getContext('webgl');
  1. 初始化着色器

3.1 定义创建shader对象方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
function loadShader (gl, type, sourceStr) {
// 通过gl 根据类型建立一个着色器对象
// type : gl.VERTEX_SHADER 顶点着色器 gl.FRAGMENT_SHADER
var shader = gl.createShader(type);

// 设置着色器代码
gl.shaderSource(shader, sourceStr);

// 编译着色器代码
gl.compileShader(shader);

// 处理编译结果
var result = gl.getShaderParameter(shader, gl.COMPILE_STATUS);
if (!result) {
var error = gl.getShaderInfoLog(shader);
console.error('Failed to compile shader: ' + error);
// 清除生成的shader
gl.deleteShader(shader);
return null;
}
// 编译成功返回对象
return shader;
}

3.2 定义创建program对象方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
function createProgram (gl, vshader, fshader) {
//创建顶点和片元着色器对象
var vertexShader = loadShader(gl, gl.VERTEX_SHADER, vshader);
var fragmentShader = loadShader(gl, gl.FRAGMENT_SHADER, fshader);
if (!vertexShader || !fragmentShader) {
return null;
}

// 开始构建program
var program = gl.createProgram();
if (!program) {
return null;
}

// 将shader对象装载至program
gl.attachShader(program, vertexShader);
gl.attachShader(program, fragmentShader);

// 将webgl上下文连接程序对象
gl.linkProgram(program);

// 检查连接结果
// 获取连接状态的参数信息
var linked = gl.getProgramParameter(program, gl.LINK_STATUS);
if (!linked) {
// 获取错误信息
var error = gl.getProgramInfoLog(program);
console.log(error);
// 删除之前装配至上下文的程序和着色器
gl.deleteProgram(program);
gl.deleteShader(fragmentShader);
gl.deleteShader(vertexShader);
return null;
}
// 连接正常 返回program
return program;
}

3.3 定义初始化着色器方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function initShaders (gl, vshader, fshader) {
var program = createProgram(gl, vshader, fshader);
if (!program) {
console.log('create program failed!');
return false;
}

// 程序创建正常
gl.useProgram(program);
// 定义在上下文属性
gl.program = program;

return true;
}

initShaders(gl, shaderPointer, shaderFrag);

// 指定清空画布的颜色
gl.clearColor(0.0, 0.0, 0.4, 1);
// 清空canvas
gl.clear(gl.COLOR_BUFFER_BIT);
  1. 顶点着色器的定义

在这里,demo想要展示一个平面的画布并在上面进行webgl像素渲染。这时候需要通过顶点着色器去构造一个画布,即为一个矩形。

这里设置顶点着色器的代码非常简单,至于代码详细大家可以参考glsl的语法。

1
2
3
4
5
6
const shaderPointer = `
attribute vec4 a_Position;
void main() {
gl_Position = a_Position;
gl_PointSize = 10.0;
}`;

这里设置了顶点着色器的顶点坐标和尺寸,然后这里的a_Position只是一个定义了一个vec4类型的参数。下面需要通过js代码将对应的数据传递进去。

因为在这之前,我们执行了initShader方法,从而让我们可以轻松的获取到glsl里相关的顶点属性参数。

const aPosition = gl.getAttribLocation(gl.program, ‘a_Position’);

但是我们需要的是一个矩形图像,而不是一个点,这样一来我们需要传递4个坐标参数来实现。
所以我们需要引入一个缓冲区对象的概念来进行参数传递。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// 创建缓冲区对象
let vertexBuff = gl.createBuffer();
// 将缓冲区对象绑定到目标
gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuff);
// 定义浮点类型数组存放四个坐标的数据
let vertices = new Float32Array([1.0, 1.0, 1.0, -1.0, -1.0, -1.0, -1.0, 1.0]);
// 将数据写入缓冲区 (STATIC_DRAW 数据一次写入多次绘制)
gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW);
// 将缓冲区对象分配到attribute变量
gl.vertexAttribPointer(
aPosition,
2, // 2 代表着x, y, z三个参数只有x, y 起作用。只选取前两个分量
gl.FLOAT,
false,
0,
0
);

// 开启attribute变量让顶点着色器能够访问到缓冲区内的数据
gl.enableVertexAttribArray(aPosition);

// 从0号顶点开始绘制,绘制4个坐标形成矩形。
// TRIANGLE_FAN 是由v0, v1, v2 三角和v0, v2, v3三角组成的图形。在当前坐标下为一个矩形
// TRIANGLE_STRIP 则是由v0, v1, v2 与 v1, v2, v3组成的图形,在当前坐标系为一个旗帜飘带的形状

gl.drawArrays(gl.TRIANGLE_FAN, 0, 4);

如图:

alt text

alt text

在实践了如上的一些基本代码后,我们就可以创建出矩形图形。

  1. 片元着色器

在之前我们刚通过顶点shader定义了一个矩形。那么片元着色器解释起来可能比较复杂,大家可以理解为其在光栅化后很类似于像素,但又不是像素。
我们可以在顶点着色器所定义的多个三角形组合的图形中,进行特定的渲染效果。

这是一段片元着色器代码,可能比较复杂,主要是实现多种正弦,余弦的几何图像变换。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
const shaderFrag = `
precision highp float;
uniform vec2 u_resolution;
uniform float time;
uniform float point[16];
const float PI = acos(-1.0);
const float TAU = PI * 2.0;
const float phi = sqrt(5.0) * 0.5 + 0.5;

const float goldenAngle = TAU / phi / phi;

vec2 rotateAroundPoint(float x){
return vec2(sin(x), cos(x));
}

vec3 hsv2rgb(vec3 c)
{
const vec4 K = vec4(1.0, 2.0 / 3.0, 1.0 / 3.0, 3.0);

vec3 p = abs(fract(c.xxx + K.xyz) * 6.0 - K.www);
return c.z * mix(K.xxx, clamp(p - K.xxx, 0.0, 1.0), c.y);
}

vec3 calculateGoldenShape(vec2 p){
const int steps = 128;
const float rSteps = 1.0 / float(steps);

vec3 result = vec3(0.0);

for (int i = 0; i < steps; ++i)
{
float n = float(i);

float inc = n * rSteps;
vec2 offset = rotateAroundPoint(fract(-100.0*0.055)*6.28+n * goldenAngle*sin(abs(point[i * 4]*0.0025))) * inc * abs(point[i * 4]/340.0);

vec3 dist = vec3(distance(p, offset));
dist = exp2(-dist * 128.0) * hsv2rgb(vec3(fract(time*0.2)+inc*0.75, 1.0, 1.0));

result = max(result, dist);
}

return result;
}

void main( void ) {

vec2 position = (gl_FragCoord.xy / u_resolution.xy - 0.5) * vec2(u_resolution.x/ u_resolution.y, 1.0);

vec3 color = vec3(0.0);
color += sin(calculateGoldenShape(position) * 3.0);
color = pow(color, vec3(2.2));
color /= sin(color + 1.0);
color = pow(color, vec3(1.0 / 2.2));


gl_FragColor = vec4(color, 1.0 );

}
`;

同时我们通过js获取相关参数并传递数据:

1
2
3
4
5
6
7
// 设置片元渲染的范围,数值可以使用像素点数量。
const resolutionUniformLocation = gl.getUniformLocation(gl.program, "u_resolution");
gl.uniform2f(resolutionUniformLocation, gl.canvas.width, gl.canvas.height);
// 设置时间间隔线性变化
const time = gl.getUniformLocation(gl.program, 'time');
// 设置多个渲染点的数据变化(接入音频数据)
let point = gl.getUniformLocation(gl.program, 'point');
  1. 获取音频数据并刷新帧时更新渲染
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function draw () {
if (t > 100) {
t = 0.0;
} else {
t += 0.02;
}
if (tt > 3) {
analyserNode.getFloatFrequencyData(dataArray);
gl.uniform1fv(point, dataArray);
gl.uniform1f(time, t);
gl.drawArrays(gl.TRIANGLE_FAN, 0, 4);
tt = 0;
} else {
tt += 1;
}
requestAnimationFrame(draw);
}

最后我们一起看看效果吧!!!

最好在chrome上看效果

因为不同机型的webgl支持不同,支持最大uniform的数目也不相同。可能会出现too many uniform的报错。
实测pc和ios7p以上设备都可以正常运行
uniform的相关报错问题会在之后的研究中去解决。