You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

19 KiB

31 | 针对海量数据,如何优化性能?

你好,我是月影。

前两节课我们一起学习了Canvas2D和WebGL性能优化的一些基本原则和处理方法。在正确运用这些方法后我们能让渲染性能达到较高的程度满足我们项目的需要。

不过,在数据量特别多的时候,我们会遇到些特殊的渲染需求,比如,要在一个地图上标记非常多的地理位置点(数千到数万),或者在地图上同时需要渲染几万条黑客攻击和防御数据。这些需求可能超过了常规优化手段所能达到的层次,需要我们针对数据和渲染的特点进行性能优化。

今天我通过渲染动态地理位置的例子来和你说说如何对特殊渲染需求迭代优化。不过我今天用到特殊优化手段只是一种具体的方法和手段你可以借鉴他去理解思路但千万不要陷入到思维定式中。因为解决这些特殊渲染需求并没有固定的路径或方法它是一个需要迭代优化的过程需要我们对WebGL的渲染机制非常了解并深入思考才能创造出最适合的方法来。在我们实际的工作里还有许多其他的方法可以使用你一定要根据自己的实际情况随机应变。

渲染动态的地理位置

我先来看今天要实现的例子。在地图可视化应用中,渲染地理位置信息是一类常见的需求,例如在这张地图上,我们就用许多不同颜色的小圆点标注出了美国一些不同的地区。

如果我们要实现这些静态的标准点方法其实很简单用Canvas2D或者WebGL都可以轻松实现。就算点数量比较多也没关系因为一次性渲染对性能影响也不会很大。不过如果我们想让圆点运动起来比如做出一种闪烁或者呼吸灯的效果那我们就要考虑点的数量对性能的影响了。

那面对这一类特殊的渲染的需求,我们该怎么办呢?下面,我们先用常规的做法来实现,然后在这个方法上不断迭代优化。为了方便你理解,我就不绘制地图了,只绘制这些随机的小圆点。

最简单的做法当然是一个一个圆绘制上去,也就是先创建圆的几何顶点数据,然后对每个圆设置不同的参数来分别绘制。实现代码如下:

const canvas = document.querySelector('canvas');
const renderer = new GlRenderer(canvas);

const vertex = `
  attribute vec2 a_vertexPosition;
  uniform vec2 xy;
  uniform float uTime;
  uniform float bias;

  void main() {
    vec3 pos = vec3(a_vertexPosition, 1);
    float scale = 0.7 + 0.3 * sin(6.28 * bias + 0.003 * uTime);
    mat3 m = mat3(
      scale, 0, 0,
      0, scale, 0,
      xy, 1
    );
    gl_Position = vec4(m * pos, 1);
  }
`;

const fragment = `
  #ifdef GL_ES
  precision highp float;
  #endif

  uniform vec4 u_color;
  
  void main() {
    gl_FragColor = u_color;
  }
`;
const program = renderer.compileSync(fragment, vertex);
renderer.useProgram(program);

function circle(radius = 0.05) {
  const delta = 2 * Math.PI / 32;
  const positions = [];
  const cells = [];
  for(let i = 0; i < 32; i++) {
    const angle = i * delta;
    positions.push([radius * Math.sin(angle), radius * Math.cos(angle)]);
    if(i > 0 && i < 31) {
      cells.push([0, i, i + 1]);
    }
  }
  return {positions, cells};
}

const COUNT = 500;
function init() {
  const meshData = [];
  const {positions, cells} = circle();
  for(let i = 0; i < COUNT; i++) {
    const x = 2 * Math.random() - 1;
    const y = 2 * Math.random() - 1;
    const rotation = 2 * Math.PI * Math.random();
    const uniforms = {};

    uniforms.u_color = [
      Math.random(),
      Math.random(),
      Math.random(),
      1];

    uniforms.xy = [
      2 * Math.random() - 1,
      2 * Math.random() - 1,
    ];

    uniforms.bias = Math.random();

    meshData.push({
      positions,
      cells,
      uniforms,
    });
  }
  renderer.uniforms.uTime = 0;
  renderer.setMeshData(meshData);
}
init();

function update(t) {
  renderer.uniforms.uTime = t;
  renderer.render();
  requestAnimationFrame(update);
}

update(0);

上面的代码非常简单关键思路就是用circle生成顶点信息然后对每个需要绘制的圆应用circle顶点信息并设置不同的unifom参数最后在shader中根据参数进行绘制就可以了。

不过如果我们这么做的话整体的性能就会非常低比如在绘制500个圆的时候浏览器的帧率就掉到十几fps了。那我们该怎么优化呢

优化大数据渲染的常见方法

我们通过前面的学习已经知道渲染次数和每次渲染的顶点计算次数是影响渲染性能的要素,所以优化大数据渲染的思路方向自然就是减少渲染次数和减少几何体顶点数了。

1. 使用批量渲染优化

在学完Canvas和WebGL的性能优化之后我们知道在绘制大量同种几何图形的时候通过减少渲染次数来提升性能最好的做法是直接使用批量渲染。针对今天的例子也是一样我们稍微修改一下上面的代码用实例渲染来代替逐个渲染代码如下

const canvas = document.querySelector('canvas');
const renderer = new GlRenderer(canvas);

const vertex = `
  attribute vec2 a_vertexPosition;
  attribute vec4 color;
  attribute vec2 xy;
  attribute float bias;
  uniform float uTime;

  varying vec4 vColor;

  void main() {
    vec3 pos = vec3(a_vertexPosition, 1);
    float scale = 0.7 + 0.3 * sin(6.28 * bias + 0.003 * uTime);
    mat3 m = mat3(
      scale, 0, 0,
      0, scale, 0,
      xy, 1
    );
    vColor = color;
    gl_Position = vec4(m * pos, 1);
  }
`;

const fragment = `
  #ifdef GL_ES
  precision highp float;
  #endif


  varying vec4 vColor;
  
  void main() {
    gl_FragColor = vColor;
  }
`;
const program = renderer.compileSync(fragment, vertex);
renderer.useProgram(program);

function circle(radius = 0.05) {
  const delta = 2 * Math.PI / 32;
  const positions = [];
  const cells = [];
  for(let i = 0; i < 32; i++) {
    const angle = i * delta;
    positions.push([radius * Math.sin(angle), radius * Math.cos(angle)]);
    if(i > 0 && i < 31) {
      cells.push([0, i, i + 1]);
    }
  }
  return {positions, cells};
}

const COUNT = 200000;
function init() {
  const {positions, cells} = circle();
  const colors = [];
  const pos = [];
  const bias = [];
  for(let i = 0; i < COUNT; i++) {
    const x = 2 * Math.random() - 1;
    const y = 2 * Math.random() - 1;
    const rotation = 2 * Math.PI * Math.random();

    colors.push([
      Math.random(),
      Math.random(),
      Math.random(),
      1
    ]);

    pos.push([
      2 * Math.random() - 1,
      2 * Math.random() - 1
    ]);

    bias.push(
      Math.random()
    );
  }
  
  renderer.uniforms.uTime = 0;
  renderer.setMeshData({
    positions,
    cells,
    instanceCount: COUNT,
    attributes: {
      color: {data: [...colors], divisor: 1},
      xy: {data: [...pos], divisor: 1},
      bias: {data: [...bias], divisor: 1},
    },
  });
}
init();

function update(t) {
  renderer.uniforms.uTime = t;
  renderer.render();
  requestAnimationFrame(update);
}

update(0);

你可以比较一下上面的代码和前一个例子代码的差异这里我们使用实例渲染将之前的uniform变量替换成attribute变量其他的逻辑几乎不变。我们这么做了之后即使渲染100000个点浏览器的帧率也能达到30fps以上性能提升了超过2000倍

之所以批量渲染的性能比逐个渲染要高得多是因为我们通过减少绘制次数大大减少了JavaScript与WebGL底层交互的时间。不过使用批量渲染绘制20000个点就达到我们的性能极限了吗显然没有我们还可以运用其他的优化手段。

2. 使用点图元优化

绘制规则的图形我们还可以使用点图元。还记得吗我们说过WebGL的基本图元包括点、线、三角形等等。前面我们绘制圆的时候都是用circle函数生成三角网格然后通过三角形绘制的。这样我们绘制一个圆需要许多顶点。但实际上这种简单的图形我们还可以直接采用点图元。

点图元造型

在WebGL中点图元是最简单的图元它用来显示画布上的点。在顶点着色器里我们可以设置gl_PointSize来改变点图元的大小所以我们就可以用点图元来表示一个矩形。我们看下面这个例子。

const canvas = document.querySelector('canvas');
const renderer = new GlRenderer(canvas);

const vertex = `
  attribute vec2 a_vertexPosition;
  uniform vec2 uResolution;

  void main() {
    gl_PointSize = 0.2 * uResolution.x;
    gl_Position = vec4(a_vertexPosition, 1, 1);
  }
`;

const fragment = `
  #ifdef GL_ES
  precision highp float;
  #endif
  
  void main() {
    gl_FragColor = vec4(0, 0, 1, 1);
  }
`;
const program = renderer.compileSync(fragment, vertex);
renderer.useProgram(program);

renderer.uniforms.uResolution = [canvas.width, canvas.height];
renderer.setMeshData({
  mode: renderer.gl.POINTS,
  positions: [[0, 0]],
});

renderer.render();

如上面代码所示我们将meshData的mode设为gl.POINTS只绘制一个点0, 0。在顶点着色器中我们通过gl_PointSize来设置顶点的大小。由于gl_PointSize的单位是像素所以我们需要传一个画布宽高uResolution进去然后将gl_Position设为0.2 * uResolution这就让这个点的大小设为画布的20%,最终在画布上就呈现出一个蓝色矩形。

注意,这里你可以回顾一下之前我们采用的常规的方法绘制的矩形,我们是将矩形剖分为两个三角形,然后用填充三角形来绘制的。而这里,我们用点图元,好处是我们只需要一个顶点就可以绘制,而不需要用四个顶点、两个三角形来填充。

现在我们通过点图元改变gl_PointSize绘制出了矩形那怎么才能绘制出其他图形呢实际上答案在我们前面学过的课程里——使用距离场和造型函数。

在上面例子的基础上,我们修改一下顶点着色器和片元着色器。具体代码如下:

首先是顶点着色器代码。

attribute vec2 a_vertexPosition;

uniform vec2 uResolution;
varying vec2 vResolution;
varying vec2 vPos;

void main() {
  gl_PointSize = 0.2 * uResolution.x;
  vResolution = uResolution;
  vPos = a_vertexPosition;
  gl_Position = vec4(a_vertexPosition, 1, 1);
}

然后是片元着色器代码。

#ifdef GL_ES
precision highp float;
#endif

varying vec2 vResolution;
varying vec2 vPos;

void main() {
  vec2 st = gl_FragCoord.xy / vResolution;
  st = 2.0 * st - 1.0;
  float d = distance(st, vPos);
  d = 1.0 - smoothstep(0.195, 0.2, d);
  gl_FragColor = d * vec4(0, 0, 1, 1);
}

经过前面课程的学习你应该对造型函数的实现原理比较熟悉了这里我们就是通过计算到圆心的距离得出距离场然后通过smoothstep将一定距离内的图形绘制出来这样就得到一个蓝色的圆。

用这样的思路呢,我们就可以得到新的绘制大量圆的方法了。这种思路实现圆的代码如下:

const canvas = document.querySelector('canvas');
const renderer = new GlRenderer(canvas);

const vertex = `
  attribute vec2 a_vertexPosition;
  attribute vec4 color;
  attribute float bias;

  uniform float uTime;
  uniform vec2 uResolution;

  varying vec4 vColor;
  varying vec2 vPos;
  varying vec2 vResolution;
  varying float vScale;

  void main() {
    float scale = 0.7 + 0.3 * sin(6.28 * bias + 0.003 * uTime);
    gl_PointSize = 0.05 * uResolution.x * scale;
    vColor = color;
    vPos = a_vertexPosition;
    vResolution = uResolution;
    vScale = scale;
    gl_Position = vec4(a_vertexPosition, 1, 1);
  }
`;


const fragment = `
  #ifdef GL_ES
  precision highp float;
  #endif

  varying vec4 vColor;
  varying vec2 vPos;
  varying vec2 vResolution;
  varying float vScale;
  
  void main() {
    vec2 st = gl_FragCoord.xy / vResolution;
    st = 2.0 * st - vec2(1);
    float d = step(distance(vPos, st), 0.05 * vScale);
    gl_FragColor = d * vColor;
  }
`;
const program = renderer.compileSync(fragment, vertex);
renderer.useProgram(program);

const COUNT = 200000;
function init() {
  const colors = [];
  const pos = [];
  const bias = [];
  for(let i = 0; i < COUNT; i++) {
    const x = 2 * Math.random() - 1;
    const y = 2 * Math.random() - 1;
    const rotation = 2 * Math.PI * Math.random();

    colors.push([
      Math.random(),
      Math.random(),
      Math.random(),
      1
    ]);

    pos.push([
      2 * Math.random() - 1,
      2 * Math.random() - 1
    ]);

    bias.push(
      Math.random()
    );
  }

  renderer.uniforms.uTime = 0;
  renderer.uniforms.uResolution = [canvas.width, canvas.height];

  renderer.setMeshData({
    mode: renderer.gl.POINTS,
    enableBlend: true,
    positions: pos,
    attributes: {
      color: {data: [...colors]},
      bias: {data: [...bias]},
    },
  });
}
init();


function update(t) {
  renderer.uniforms.uTime = t;
  renderer.render();
  requestAnimationFrame(update);
}

update(0);

可以看到我们没有采用前面那样通过circle函数来生成圆的顶点数据而是直接使用gl.POINTS来绘制并在着色器中用距离场和造型函数来画圆。这么做之后我们大大减少了顶点的运算原先我们每绘制一个圆需要32个顶点、30个三角形而现在用一个点就解决了问题。这样一来就算我们要渲染200000个点帧率也可以保持在50fps以上性能又提升了超过一倍

其他方法

这里举上面的这个例子主要是想说明一个问题即使是使用WebGL不同的渲染方式性能的差别也会很大甚至会达到数千倍的差别。因此在可视化业务中我们一定要学会根据不同的应用场景来有针对性地进行优化。说起来简单要做到这一点并不容易你需要对WebGL本身非常熟悉而且对于GPU的使用、渲染管线等基本原理有着比较深刻的理解。这不是一朝一夕可以做到的需要持续不断地学习和积累。

就像有些同学使用绘图库ThreeJS或者SpriteJS来绘图的时候做出来的应用性能很差就会怀疑是图形库本身的问题。实际上这些问题很可能不是库本身的问题而是我们使用方法上的问题。换句话说是我们使用的绘图方式并不是最适用于当前的业务场景。而ThreeJS、SpriteJS这些通用的绘图库也并不会自己针对特定场景来优化。

因此单纯使用图形库我们绘制出来的图形就没法真正达到性能极致。也正是因为这个原因我没有把这门课程的重点放在库的API的使用上而是深入到图形渲染的底层原理。只有掌握了这些你才能真正学会如何驾驭图形库做出高性能的可视化解决方案来。

针对场景的性能优化方法其实非常多,我刚才讲的也只是几种典型的情况。为了帮助你在实战中慢慢领悟,我再举几个例子。不过我要提前说一下,我不会具体去讲这些例子的代码,只会重点强调常用的思路。学会这些方法之后,你再在实践中慢慢应用和体会就会容易很多了。

1. 使用后期处理通道优化

我们已经学习过使用后期处理通道的基本方法。实际上后期处理通道十分强大它最重要的特性就是可以把各种数据存储在纹理图片中。这样在迭代处理的时候我们就可以用GPU将这些数据并行地读取和处理从而达到非常高效地渲染。

这里是一个OGL官网上例子它就是用后期处理通道实现了粒子流的效果。这样的效果在其他图形系统中或者WebGL不使用后期处理通道是不可能做到的。

这里的具体实现比较复杂但其中最关键的一点是我们要将每个像素点的速度值保存到纹理图片中然后利用GPU并行计算的能力对每个像素点同时进行处理。

2. 使用GPGPU优化

还有一种优化思路和后期处理通道很像刚好OGL官网也有这个例子它使用了一种叫做GPGPU的方式也叫做通用GPU方式就是把每个粒子的速度保存到纹理图片里实现同时渲染几万个粒子并产生运动的效果。

3. 使用服务端渲染优化

最后一种优化思路,是从我之前做过的一个可视化项目中提取出来的。当时,我需要渲染数十万条历史数据的记录,如果单纯在前端渲染,性能会成为瓶颈。但由于这些数据都是历史数据,因此针对这个场景我们可以在服务端进行渲染,然后直接将渲染后的图片输出给前端。

要使用服务端渲染,我们可以使用 Node-canvas-webgl 这个库它可以在Node.js中启动一个Canvas2D和WebGL环境这样我们就可以在服务端进行渲染然后再将结果缓存起来直接提供给客户端。

要点总结

这节课我们主要讲了针对业务的不同应用场景进行性能优化的思路和方法。尤其是在海量数据的情况下,特定优化手段显得十分重要,甚至有可能产生上千倍的性能差距。

结合今天的例子对于要绘制大量圆形的场景我们用常规的处理方法渲染500个元素都比较吃力一旦我们使用了批量渲染就可以把性能一下子提升两千倍以上能够轻松渲染10万个元素如果我们再使用点图元结合造型函数的方法就能轻松渲染20万个以上的元素了。像这样的优化方法需要我们理解业务场景并对WebGL和GPU、渲染管线等有深入的理解再在项目实践中慢慢积累。

除此以外我们还简单介绍了其他的一些优化手段包括使用后期处理通道、使用GPGPU和使用服务端渲染。你可以结合我给出的例子去深入理解。

小试牛刀

今天,我们使用点图元结合造型函数的思路,绘制了正方形和圆,你还能绘制出其他不同图形吗?比如圆、正方形、菱形、花瓣、苹果或葫芦等等。

欢迎在留言区和我讨论,分享你的答案和思考,也欢迎你把这节课分享给你的朋友,我们下节课见!


源码

课程中完整示例代码

推荐阅读

1
2
3