This commit is contained in:
louzefeng
2024-07-11 05:50:32 +00:00
parent bf99793fd0
commit d3828a7aee
6071 changed files with 0 additions and 0 deletions

View File

@@ -0,0 +1,304 @@
<audio id="audio" title="28 | Canvas、SVG与WebGL在性能上的优势与劣势" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/a9/27/a9e59409a006e3d5a01d8f8cd43f8027.mp3"></audio>
你好,我是月影。
性能优化,一直以来都是前端开发的难点。
我们知道前端性能是一块比较复杂的内容由许多因素决定比如网页内容和资源文件的大小、请求数、域名、服务器配置、CDN等等。如果你能把性能优化好就能极大地增强用户体验。
在可视化领域也一样,可视化因为要突出数据表达的内容,经常需要设计一些有视觉震撼力的图形效果,比如,复杂的粒子效果和大量元素的动态效果。想要实现这些效果,图形系统的渲染性能就必须非常好,能够在用户的浏览器上稳定流畅地渲染出想要的视觉效果。
那么针对可视化渲染,我们都要解决哪些性能问题呢?
## 可视化渲染的性能问题有哪些?
由于前端的可视化也是在Web上展现的因此像网页大小这些因素也会影响它的性能。而且无论是可视化还是普通Web前端针对这些因素进行性能优化的原理和手段都一样。
所以我今天想和你聊的是可视化方面特殊的性能问题。它们在我们熟悉的Web前端工作中并不常见通常只在可视化中绘制复杂图形的时候我们才需要重点考虑。这些问题大体上可以分为两类一类是**渲染效率问题,<strong>另一类是**计算问题</strong>
**我们先来看它们的定义,渲染效率问题指的是图形系统在绘图部分所花费的时间,而计算问题则是指绘图之外的其他处理所花费的时间,包括图形数据的计算、正常的程序逻辑处理等等**
我们知道在浏览器上渲染动画每一秒钟最高达到60帧左右。也就是说我们可以在1秒钟内完成60次图像的绘制那么完成一次图像绘制的时间就是1000/601秒=1000毫秒约等于16毫秒。
换句话说如果我们能在16毫秒内完成图像的计算与渲染过程那视觉呈现就可以达到完美的60fps即60帧每秒fps全称是frame per second是帧率单位。但是在复杂的图形渲染时我们的帧率很可能达不到60fps。
所以我们只能退而求其次最低可以选择24fps就相当于图形系统要在大约42毫秒内完成一帧图像的绘制。这是在我们的感知里达到比较流畅的动画效果的最低帧率了。要保证这个帧率我们就必须保证计算加上渲染的时间不能超过42毫秒。
因为计算问题与数据和算法有关,所以我们后面会专门讨论。这里,我们先关注渲染效率的问题,这个问题和图形系统息息相关。
我们知道Canvas2D、SVG和WebGL等图形系统各自的特点不同所以它们在绘制不同图形时的性能影响也不同会表现出不同的性能瓶颈。其实通过基础篇的学习我们也大体上知道了这些图形系统的区别和优劣。那今天我们就在此基础上深入讨论一下影响它们各自性能的关键因素理解了这些要素我们针对不同图形系统就能快速找到需要进行性能优化的点了。
## 影响Canvas渲染性能的2大要素
我们知道Canvas是指令式绘图系统它通过绘图指令来完成图形的绘制。那么我们很容易就会想到2个影响因素首先绘制图形的数量越多我们需要的绘图指令就越多花费的渲染时间也会越多。其次画布上绘制的图形越大绘图指令执行的时间也会增多那么花费的渲染时间也会越多。
这些其实都是我们现阶段得出的假设,而实践是检验真理的唯一标准,所以我们一起做个实验,来证明我们刚才的假设吧。
```
const canvas = document.querySelector('canvas');
const ctx = canvas.getContext('2d');
const WIDTH = canvas.width;
const HEIGHT = canvas.height;
function randomColor() {
return `hsl(${Math.random() * 360}, 100%, 50%)`;
}
function drawCircle(context, radius) {
const x = Math.random() * WIDTH;
const y = Math.random() * HEIGHT;
const fillColor = randomColor();
context.fillStyle = fillColor;
context.beginPath();
context.arc(x, y, radius, 0, Math.PI * 2);
context.fill();
}
function draw(context, count = 500, radius = 10) {
for(let i = 0; i &lt; count; i++) {
drawCircle(context, radius);
}
}
requestAnimationFrame(function update() {
ctx.clearRect(0, 0, WIDTH, HEIGHT);
draw(ctx);
requestAnimationFrame(update);
});
```
如上面代码所示我们在Canvas上每一帧绘制500个半径为10的小圆效果如下
<img src="https://static001.geekbang.org/resource/image/b2/e3/b278a4f98413b9029dfa914ab4b88be3.jpg" alt="" title="500个小球半径10">
注意为了方便查看帧率的变化我们在浏览器中开启了帧率检测。Chrome开发者工具自带这个功能我们在开发者工具的Rendering标签页中勾选FPS Meter就可以开启这个功能查看帧率了。
我们现在看到即使每帧渲染500个位置和颜色都随机的小圆形Canvas渲染的帧率依然能达到60fps。
接着我们增加小球的数量把它增加到1000个。
<img src="https://static001.geekbang.org/resource/image/8c/f9/8cbe753219d08a1b84753fe5f89518f9.jpg" alt="" title="1000个小球半径10">
这时你可以看到因为小球数量增加一倍所以帧率掉到了50fps左右现在下降得还不算太多。而如果我们把小球的数量设置成3000你就能看到明显的差别了。
那如果我们把小球的数量保持在500把半径增大到很大如200也会看到帧率有明显下降。
<img src="https://static001.geekbang.org/resource/image/ee/81/ee69d57ce2b1214f55650f8c3f8d9681.jpg" alt="" title="500个小球半径200">
但是单从上图的实验来看图形大小对帧率的影响也不是很大。因为我们把小球的半径增加了20倍帧率也就下降到33fps。当然这也是因为画圆比较简单如果我们绘制的图形更复杂一些那么大小的影响会相对显著一些。
通过这个实验我们能得出影响Canvas的渲染性能的主要因素有两点一是**绘制图形的数量**,二是**绘制图形的大小。**这正好验证了我们开头的结论。
总的来说Canvas2D绘制图形的性能还是比较高的。在普通的个人电脑上我们要绘制的图形不太大时只要不超过500个都可以达到60fps1000个左右其实也能达到50fps就算要绘制大约3000个图形也能够保持在可以接受的24fps以上。
因此在不做特殊优化的前提下如果我们使用Canvas2D来绘图那么3000个左右元素是一般的应用的极限除非这个应用运行在比个人电脑的GPU和显卡更好的机器上或者采用特殊的优化手段。那具体怎么优化我会在下节课详细来说。
## 影响SVG性能的2大要素
讲完了Canvas接下来我们看一下SVG。
我们用SVG实现同样的绘制随机圆形的例子代码如下
```
function randomColor() {
return `hsl(${Math.random() * 360}, 100%, 50%)`;
}
const root = document.querySelector('svg');
const COUNT = 500;
const WIDTH = 500;
const HEIGHT = 500;
function initCircles(count = COUNT) {
for(let i = 0; i &lt; count; i++) {
const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
root.appendChild(circle);
}
return [...root.querySelectorAll('circle')];
}
const circles = initCircles();
function drawCircle(circle, radius = 10) {
const x = Math.random() * WIDTH;
const y = Math.random() * HEIGHT;
const fillColor = randomColor();
circle.setAttribute('cx', x);
circle.setAttribute('cy', y);
circle.setAttribute('r', radius);
circle.setAttribute('fill', fillColor);
}
function draw() {
for(let i = 0; i &lt; COUNT; i++) {
drawCircle(circles[i]);
}
requestAnimationFrame(draw);
}
draw();
```
在我的电脑上一台普通的MacBook Pro内存8GB独立显卡绘制了500个半径为10的小球时SVG的帧率接近60fps会比Canvas稍慢但是差别不是太大。
<img src="https://static001.geekbang.org/resource/image/01/fa/01f6d0d37a0aeabd8449dyyecfc4e2fa.jpg" alt="" title="SVG绘制500个小球半径10">
当我们将小球数量增加到1000个时SVG的帧率就要略差一些大概45fps左右。
<img src="https://static001.geekbang.org/resource/image/4e/b6/4efb6930462da79f36e913546f5eb1b6.jpg" alt="" title="SVG绘制1000个小球半径10">
乍一看似乎SVG和Canvas2D的性能差别也不是很大。不过随着小球数量的增加两者的差别会越来越大。比如说当我们将小球的个数增加到3000个左右的时候Canvas2D渲染的帧率依然保持在30fps以上而SVG渲染帧率大约只有15fps差距会特别明显。
之所以在小球个数较多的时候二者差距很大因为SVG是浏览器DOM来渲染的元素个数越多消耗就越大。
如果我们保证小球个数在一个小数值然后增大每个小球的半径那么与Canvas一样SVG的渲染效率也会明显下降。
<img src="https://static001.geekbang.org/resource/image/19/ea/19d991c1ee547d1f98fe2f504eaba1ea.jpg" alt="" title="SVG绘制500个小球半径200">
如上图所示当渲染500个小球时我们把半径增加到200帧率下降到不到20fps。
最终我们能得到的结论与Canvas类似影响SVG的性能因素也是相同的两点一是**绘制图形的数量**,二是**绘制图形的大小**。但与Canvas不同的是图形数量增多的时候SVG的帧率下降会更明显因此一般来说在图形数量小于1000时我们可以考虑使用SVG当图形数量大于1000但不超过3000时我们考虑使用Canvas2D。
那么当图形数量超过3000时用Canvas2D也很难达到比较理想的帧率了这时候我们就要使用WebGL渲染。
## 影响WebGL性能的要素
用WebGL渲染上面的例子我们不需要一个一个小球去渲染利用GPU的并行处理能力我们可以一次完成渲染。
因为我们要渲染的小球形状相同所以它们的顶点数据是可以共享的。在这里我们采用一种WebGL支持的批量绘制技术叫做**InstancedDrawing实例化渲染**。在OGL库中我们只需要给几何体数据传递带有instanced属性的顶点数据就可以自动使用instanced drawing技术来批量绘制图形。具体的操作代码如下
```
function circleGeometry(gl, radius = 0.04, count = 30000, segments = 20) {
const tau = Math.PI * 2;
const position = new Float32Array(segments * 2 + 2);
const index = new Uint16Array(segments * 3);
const id = new Uint16Array(count);
for(let i = 0; i &lt; segments; i++) {
const alpha = i / segments * tau;
position.set([radius * Math.cos(alpha), radius * Math.sin(alpha)], i * 2 + 2);
}
for(let i = 0; i &lt; segments; i++) {
if(i === segments - 1) {
index.set([0, i + 1, 1], i * 3);
} else {
index.set([0, i + 1, i + 2], i * 3);
}
}
for(let i = 0; i &lt; count; i++) {
id.set([i], i);
}
return new Geometry(gl, {
position: {
data: position,
size: 2,
},
index: {
data: index,
},
id: {
instanced: 1,
size: 1,
data: id,
},
});
}
```
我们实现一个circleGeometry函数用来生成指定数量的小球的定点数据。这里我们使用批量绘制的技术一下子绘制了30000个小球。与绘制单个小球一样我们计算小球的position数据和index数据然后我们设置一个id数据这个数据等于每个小球的下标。
我们通过instanced:1的方式告诉WebGL这是一个批量绘制的数据让每一个值作用于一个几何体。这样我们就能区分不同的几何体而WebGL在绘制的时候会根据id数据的个数来绘制相应多个几何体。
接着,我们实现顶点着色器,并且在顶点着色器代码中实现随机位置和随机颜色。
```
precision highp float;
attribute vec2 position;
attribute float id;
uniform float uTime;
highp float random(vec2 co) {
highp float a = 12.9898;
highp float b = 78.233;
highp float c = 43758.5453;
highp float dt= dot(co.xy ,vec2(a,b));
highp float sn= mod(dt,3.14);
return fract(sin(sn) * c);
}
vec3 hsb2rgb(vec3 c){
vec3 rgb = clamp(abs(mod(c.x*6.0+vec3(0.0,4.0,2.0), 6.0)-3.0)-1.0, 0.0, 1.0);
rgb = rgb * rgb * (3.0 - 2.0 * rgb);
return c.z * mix(vec3(1.0), rgb, c.y);
}
varying vec3 vColor;
void main() {
vec2 offset = vec2(
1.0 - 2.0 * random(vec2(id + uTime, 100000.0)),
1.0 - 2.0 * random(vec2(id + uTime, 200000.0))
);
vec3 color = vec3(
random(vec2(id + uTime, 300000.0)),
1.0,
1.0
);
vColor = hsb2rgb(color);
gl_Position = vec4(position + offset, 0, 1);
}
```
上面的代码中的random函数和hsb2rgb函数我们都学过了整体逻辑也并不复杂相信你应该能看明白。
最后我们将uTime作为uniform传进去结合id和uTime用随机数就可以渲染出与前面Canvas和SVG例子一样的效果。
这个WebGL渲染的例子的性能非常高我们将小球的个数设置为30000个依然可以轻松达到60fps的帧率。
<img src="https://static001.geekbang.org/resource/image/84/6f/84be3a259d9d7dc0572cf8044029536f.jpg" alt="" title="WebGL绘制30000个小球半径10">
WebGL渲染之所以能达到这么高的性能是因为WebGL利用GPU并行执行的特性无论我们批量绘制多少个小球都能够同时完成计算并渲染出来。
如果我们增大小球的半径那么帧率也会明显下降这一点和Canvas2D与SVG一样。当我们将小球半径增加到0.8相当于Canvas2D中的200那么可以流畅渲染的数量就无法达到这么多大约渲染3000个左右可以保持在30fps以上这个效率仍比Canvas2D有着5倍以上的提升。小球半径增加导致帧率下降是因为图形增大片元着色器要执行的次数就会增多就会增加GPU运算的开销。
好了那我们来总结一下WebGL性能的要素。WebGL情况比较复杂上面的例子其实不能涵盖所有的情况不过不要紧我这里先说一下结论你先记下来我们之后还会专门讨论WebGL的性能优化方法。
首先WebGL和Canvas2D与SVG不同它的性能并不直接与渲染元素的数量相关而是取决于WebGL的渲染次数。有的时候图形元素虽然很多但是WebGL可以批量渲染就像前面的例子中虽然有上万个小球但是通过WebGL的instanced drawing技术可以批量完成渲染那样它的性能就会很高。当然元素的数量多WebGL渲染效率也会逐渐降低这是因为元素越多本身渲染耗费的内存也越多占用内存太多渲染效率也会下降。
其次在渲染次数相同的情况下WebGL的效率取决于着色器中的计算复杂度和执行次数。图形顶点越多顶点着色器的执行次数越多图形越大片元着色器的执行次数越多虽然是并行执行但执行次数多依然会有更大的性能开销。最后如果每次执行着色器中的计算越复杂WebGL渲染的性能开销自然也会越大。
总的来说WebGL的性能主要有三点决定因素**一是渲染次数,二是着色器执行的次数,三是着色器运算的复杂度。**当然,数据的大小也会决定内存的消耗,因此也会对性能有所影响,只不过影响没有前面三点那么明显。
## 要点总结
要针对可视化的渲染效率进行性能优化,我们就要先搞清影响图形系统渲染性能的主要因素。
对于Canvas和SVG来说影响渲染性能的主要是绘制元素的数量和元素的大小。一般来说Canvas和SVG绘制的元素越多性能消耗越大绘制的图形越大性能消耗也越大。相比较而言Canvas的整体性能要优于SVG尤其是图形越多二者的性能差异越大。
WebGL要复杂一些它的渲染性能主要取决于三点。
第一点是渲染次数渲染次数越多性能损耗就越大。需注意要绘制的元素个数多不一定渲染次数就多因为WebGL支持批量渲染。
第二点是着色器执行的次数,这里包括顶点着色器和片元着色器,前者的执行次数和几何图形的顶点数有关,后者的执行次数和图形的大小有关。
第三点是着色器运算的复杂度复杂度和glsl代码的具体实现有关越复杂的处理逻辑性能的消耗就会越大。
最后数据的大小会影响内存消耗所以也会对WebGL的渲染性能有所影响不过没有前面三点的影响大。
## 小试牛刀
<li>
刚才我们用SVG、Canvas和WebGL分别实现了随机小球由此比较了三种图形系统的性能。但是我们并没说HTML/CSS你能用HTML/CSS来实现这个例子吗用HTML/CSS来实现在性能方面与SVG、Canvas和WebGL有什么区别呢从中你能得出影响HTML/CSS渲染性能的要素吗
</li>
<li>
在WebGL的例子中我们采用了批量绘制的技术。实际上我们也可以不采用这个技术给每个小球生成一个mesh对象然后让Ogl来渲染。你可以试着用Ogl不采用批量渲染来实现随机小球然后对比它们之间的渲染方案得出性能方面的差异吗?
</li>
## 源码
[课程中详细示例代码](https://github.com/akira-cn/graphics/tree/master/performance-basic)

View File

@@ -0,0 +1,415 @@
<audio id="audio" title="29 | 怎么给Canvas绘制加速" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/ed/33/ed06f10b056dca8af21f2f7dd8494433.mp3"></audio>
你好,我是月影。
上节课,我们从宏观上了解了各个图形系统在性能方面的优劣,以及影响性能的要素。实际上,想要解决性能问题,我们就必须要知道真正消耗性能的点,从而结合项目需求进行有针对的处理,否则性能优化就是纸上谈兵、空中楼阁。
所以这节课我们就深入讨论一下影响Canvas绘图性能的因素一起来分析几个不同类型的Canvas项目找到的性能瓶颈以及对应的解决办法从而学会对大部分Canvas项目进行性能优化。
我们知道Canvas是指令式绘图系统它有状态设置指令、绘图指令以及真正的绘图方法fill和stroke等各类API。通常情况下利用Canvas绘图我们要先调用状态设置指令设置绘图状态然后用绘图指令决定要绘制的图形最后调用真正的fill()或stroke()方法将内容输出到画布上。
那结合上节课的实验我们知道影响Canvas性能的两大因素分别是图形的数量和图形的大小。它们都会直接影响绘图指令一个决定了绘图指令的多少另一个决定了绘图指令的执行时间。通常来说绘图指令越多、执行时间越长渲染效率就越低性能也就越差。
因此我们想要对Canvas性能进行优化最重要的就是优化渲染效率。常用的手段有5种分别是优化Canvas指令、使用缓存、分层渲染、局部重绘和优化滤镜。此外还有一种手段叫做**多线程渲染**,是用来优化非渲染的计算和交互方面导致的性能问题。
首先我们来说说优化Canvas指令。
## 手段一优化Canvas指令
刚刚我们说了Canvas执行的绘图指令越多性能的消耗就越大。那如果希望Canvas绘图达到更好的性能我们要尽可能减少绘图指令的数量。这就是“优化Canvas指令”要做的事情。
那具体怎么做呢?我们看一个例子。
假设我们要在一个600 X 600的画布上实现一些位置随机的多边形并且不断刷新这些图形的形状和位置效果如下
<img src="https://static001.geekbang.org/resource/image/4e/68/4e226d7385aa48c23049b451a4525468.gif" alt="">
结合我们之前学过的知识这个效果其实并不难实现可以分为4步分别是创建多边形的顶点根据顶点绘制图形生成随机多边形执行绘制。
具体的实现代码如下:
```
const canvas = document.querySelector('canvas');
const ctx = canvas.getContext('2d');
// 创建正多边形,返回顶点
function regularShape(x, y, r, edges = 3) {
const points = [];
const delta = 2 * Math.PI / edges;
for(let i = 0; i &lt; edges; i++) {
const theta = i * delta;
points.push([x + r * Math.sin(theta), y + r * Math.cos(theta)]);
}
return points;
}
// 根据顶点绘制图形
function drawShape(context, points) {
context.fillStyle = 'red';
context.strokeStyle = 'black';
context.lineWidth = 2;
context.beginPath();
context.moveTo(...points[0]);
for(let i = 1; i &lt; points.length; i++) {
context.lineTo(...points[i]);
}
context.closePath();
context.stroke();
context.fill();
}
// 多边形类型包括正三角形、正四边形、正五边形、正六边形和正100边形
const shapeTypes = [3, 4, 5, 6, 100];
const COUNT = 1000;
// 执行绘制
function draw() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
for(let i = 0; i &lt; COUNT; i++) {
const type = shapeTypes[Math.floor(Math.random() * shapeTypes.length)];
const points = regularShape(Math.random() * canvas.width,
Math.random() * canvas.height, 10, type);
drawShape(ctx, points);
}
requestAnimationFrame(draw);
}
draw();
```
这个效果实现起来虽然不难但性能却不是很好因为它在我的Macbook Pro电脑上只有不到30fps的帧率。那问题出在哪呢我们还是要回到代码中。
我们注意到drawShape函数里的for循环它是根据顶点来绘制图形的一个点对应一条绘图指令。而在我们绘制的随机图形里有3、4、5、6边形和100边形。对于一个100边形来说它的顶点数量非常多所以Canvas需要执行的绘图指令也会非常多那绘制很多个100边形自然会造成性能问题了。因此如何减少绘制100边形的绘图指令的数量才是我们要优化的重点。具体该怎么做呢
我们知道对于半径为10的小图形来说正100边形已经完全是正圆形了所以我们可以用arc指令来替代for循环。
我们修改shapeTypes和draw函数用-1代替正100边形然后判断type是否大于0如果是就用之前的方式绘制正多边形否则用arc指令来画圆。这么做了之后整个效果的帧率就会从30fps提升到40fps效果还是比较明显的。
```
const shapeTypes = [3, 4, 5, 6, -1];
const COUNT = 1000;
const TAU = Math.PI * 2;
function draw() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
for(let i = 0; i &lt; COUNT; i++) {
const type = shapeTypes[Math.floor(Math.random() * shapeTypes.length)];
const x = Math.random() * canvas.width;
const y = Math.random() * canvas.height;
if(type &gt; 0) {
// 画正多边形
const points = regularShape(x, y, 10, type);
drawShape(ctx, points);
} else {
// 画圆
ctx.beginPath();
ctx.arc(x, y, 10, 0, TAU);
ctx.stroke();
ctx.fill();
}
}
requestAnimationFrame(draw);
}
```
到这里,你会发现,我们讲的其实是个特例,那在实际工作中,我们是需要针对特例来优化的。我希望我讲完今天的内容你能够做到举一反三。
## 手段二:使用缓存
在上面的方法中,我们优化了绘图指令,让渲染性能有了比较明显的提升。不过,因为这个绘图任务的图形数量和状态都是有限的,我们还有更好的优化方法,那就是**使用缓存**。
因为Canvas的性能瓶颈主要在绘图指令方面如果我们能将图形缓存下来保存到离屏的Canvasoffscreen Canvas然后在绘制的时候作为图像来渲染那我们就可以将绘制顶点的绘图指令变成直接通过drawImage指令来绘制图像而且也不需要fill()方法来填充图形,这样性能就会有大幅度的提升。
具体的做法,是我们先实现一个创建缓存的函数。代码如下:
```
function createCache() {
const ret = [];
for(let i = 0; i &lt; shapeTypes.length; i++) {
// 创建离屏Canvas缓存图形
const cacheCanvas = new OffscreenCanvas(20, 20);
// 将图形绘制到离屏Canvas对象上
const type = shapeTypes[i];
const context = cacheCanvas.getContext('2d');
context.fillStyle = 'red';
context.strokeStyle = 'black';
if(type &gt; 0) {
const points = regularShape(10, 10, 10, type);
drawShape(context, points);
} else {
context.beginPath();
context.arc(10, 10, 10, 0, TAU);
context.stroke();
context.fill();
}
ret.push(cacheCanvas);
}
// 将离屏Canvas数组缓存对象返回
return ret;
}
```
然后,我们一次性创建缓存,直接通过缓存来绘图。
```
const shapes = createCache();
const COUNT = 1000;
function draw() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
for(let i = 0; i &lt; COUNT; i++) {
const shape = shapes[Math.floor(Math.random() * shapeTypes.length)];
const x = Math.random() * canvas.width;
const y = Math.random() * canvas.height;
ctx.drawImage(shape, x, y);
}
requestAnimationFrame(draw);
}
```
这样我们就通过缓存渲染把原本数量非常多的绘图指令优化成了只有drawImage的一条指令让渲染帧率达到了60fps从而大大提升了性能。
### 缓存的局限性
不过虽然使用缓存能够显著降低Canvas的性能消耗但是缓存的使用也有局限性。
首先因为缓存是通过创建离屏Canvas对象实现的如果我们要绘制的图形状态指不同形状、颜色等非常多的话那将它们都缓存起来就需要创建大量的离屏Canvas对象。这本身对内存消耗就非常大有可能反而降低了性能。
其次缓存适用于图形状态本身不变的图形元素如固定的几何图形它们每次刷新只需要更新它的transform这样的图形比较适合用缓存。如果是经常发生状态改变的图形元素那么缓存就必须一直更新缓存更新本身也是绘图过程。因此这种情况下采用缓存根本起不到减少绘图指令的作用反而因为增加了一条drawImage指令产生了更大的开销。
第三严格上来说从缓存绘制和直接用绘图指令绘制还是有区别的尤其是在fillText渲染文字或者我们绘制一个图形有较大缩放scale的时候。因为不使用缓存直接绘制的是矢量图而通过缓存drawImage绘制出的则是位图所以缓存绘制的图形在清晰度上可能不是很好。
但是总体来说缓存的应用还是非常多的我们应该要掌握它的用法学会在合适的时候运用缓存来提升Canvas的渲染性能。
## 手段三:分层渲染
前面两种手段是操作Canvas上所有元素来优化性能的但有的时候我们要绘制的元素很多其中大部分元素状态是不变的只有一小部分有变化。这个时候我们又该如何进行优化呢
我们知道Canvas是将上一次绘制的内容擦除然后绘制新的内容来实现状态变化的。利用这一特点我们就可以将变化的元素和不变的元素进行分层处理。也就是说我们可以用两个Canvas叠在一起将不变的元素绘制在一个Canvas中变化的元素绘制在另一个Canvas中。
我们还是来看一个例子。
<img src="https://static001.geekbang.org/resource/image/4f/e9/4f0c60c3810d372d41ee14d90efb23e9.gif" alt="">
假设,我们要实现一个如上图的效果。这个效果的特点是,画面上有一个飞机在运动,运动的物体比较少,而其他静止不动的图形很多(如背景中的上千个三角形)。
在绘制的时候我们如果将运动的物体和其他物体都绘制在同一个Canvas画布中要改变飞机的运动状态我们就要重新绘制所有的物体这会非常浪费性能。因此更好的做法是我们使用两层画布一层Canvas作为背景来绘制静态的图形就是这个例子里的上千个小三角形而另一层Canvas作为前景用来绘制运动的物体就是运动的飞机。
这样的话我们只需要一次绘制就能得到背景层Canvas并且不管飞机的状态怎么改变我们都不需要重绘而前景的飞机可以每一帧重绘也就大大减少了图形绘制的数量并且提升了性能。
下面我列出具体的代码虽然很长但逻辑并不复杂核心就是用两个Canvas元素来分别绘制你可以看一下。
```
function drawRandomTriangle(path, context) {
const {width, height} = context.canvas;
context.save();
context.translate(Math.random() * width, Math.random() * height);
context.fill(path);
context.restore();
}
function drawBackground(context, count = 2000) {
context.fillStyle = '#ed7';
const d = 'M0,0L0,10L8.66, 5z';
const p = new Path2D(d);
for(let i = 0; i &lt; count; i++) {
drawRandomTriangle(p, context);
}
}
function loadImage(src) {
const img = new Image();
img.crossOrigin = 'anonymous';
return new Promise((resolve) =&gt; {
img.onload = resolve(img);
img.src = src;
});
}
async function drawForeground(context) {
const img = await loadImage('http://p3.qhimg.com/t015b85b72445154fe0.png');
const {width, height} = context.canvas;
function update(t) {
context.clearRect(0, 0, width, height);
context.save();
context.translate(0, 0.5 * height);
const p = (t % 3000) / 3000;
const x = width * p;
const y = 0.1 * height * Math.sin(3 * Math.PI * p);
context.drawImage(img, x, y);
context.restore();
requestAnimationFrame(update);
}
update(0);
}
const bgcanvas = document.querySelector('#bg');
const fgcanvas = document.querySelector('#fg');
drawBackground(bgcanvas.getContext('2d'));
drawForeground(fgcanvas.getContext('2d'));
```
## 手段四:局部重绘
但是,我们用分层渲染解决性能问题的时候,所绘制的图形必须满足两个条件:一是有大量静态的图形元素不需要重新绘制,二是动态和静态图形元素绘制顺序是固定的,先绘制完静态元素再绘制动态元素。如果元素都有可能运动,或者动态元素和静态元素的绘制顺序是交错的,比如先绘制几个静态元素,再绘制几个动态元素,然后再绘制静态元素,这样交替进行,那么分层渲染就不好实现了。这时候,我们还有另外一种优化手段,它叫做局部重绘。
**局部重绘顾名思义就是不需要清空Canvas的全局区域而是根据运动的元素的范围来清空部分区域。**在很大一部分可视化大屏项目中,我们不会让整个屏幕的所有元素都不断改变,而是只有一些固定的区域改变,所以我们直接刷新那部分区域,重绘区域中的元素就可以了。
<img src="https://static001.geekbang.org/resource/image/ff/16/ff209e67ac077c1c43782e0291500e16.jpeg" alt="" title="大屏的动态区与静态区">
如上图所示一个可视化大屏只有2块动态区域需要不断重绘那我们用Canvas上下文的**clearRect方法控制要刷新的动态区域**,只对这些区域进行擦除然后重绘。
要注意的是动态区重绘的时候区域内的静态元素也需要跟着重绘。如果有静态元素跨越了动态和静态区域范围那在重绘时我们自然不希望破坏了静态区的图形。这时候我们可以使用Canvas上下文的clip方法它是一种特殊的绘图指令可以设定一个绘图区让图形的绘制限制在这个绘图区内部。这样的话图形中超过clip范围的部分浏览器就不会把它渲染到Canvas上。
这种固定区域的局部重绘使用起来不难,但有时候我们不知道具体的动态区域究竟多大。这个时候,我们可以使用动态计算要重绘区域的技术,它也被称为**脏区检测**。它的基本原理是根据动态元素的**包围盒**,动态算出需要重绘的范围。
那什么是包围盒呢?
我们知道,多边形由顶点构成,包围盒就是指能包含多边形所有顶点,并且与坐标轴平行的最小矩形。
[<img src="https://static001.geekbang.org/resource/image/f2/55/f26c205843dc9479c9ce9ebd7d762855.jpeg" alt="" title="多边形包围盒">](https://www.pngsucai.com/png/950314.html)
在Canvas平面直角坐标系下求包围盒并不复杂只要分别找到所有顶点坐标中x的最大、最小值xmin和xmax以及y的最大、最小值ymin和ymax那么包围盒就是矩形[(xmin, ymin), (xmin, ymax), (xmax, ymax), (xmax, ymin)]。
对所有的动态元素计算出包围盒我们就能知道局部刷新的范围了。不过在实际操作的时候我们经常会遇到各种复杂的细节问题需要解决。因为涉及的细节比较多我没法全都讲到所以如果你遇到了问题可以看看蚂蚁金服AntV团队的[Canvas局部渲染优化总结](https://juejin.im/post/5e7c13b351882535ea43e1a2)这篇文章。
## 手段五:优化滤镜
实际上分层渲染和局部重绘解决的都是图形重绘的问题。那除了重绘影响渲染效率的还有Canvas滤镜。
我们知道滤镜是一种对图形像素进行处理的方法Canvas支持许多常用的滤镜。不过Canvas渲染滤镜的性能开销比较大。到底有多大呢我们还是用前面绘制随机图形的例子来体验一下。
这次我们用缓存优化版本的代码这一版代码的性能最高。在绘制前我们给Canvas设置一个blur滤镜。代码如下
```
ctx.filter = 'blur(5px)';
```
这样呢我们让Canvas绘制出来的图形有了模糊的效果。但是这么设置了之后你会发现原本60fps的帧率直接掉到2fps画面看上去一顿一顿的卡得惨不忍睹。这就是因为滤镜对渲染性能的开销实在太大了。
<img src="https://static001.geekbang.org/resource/image/1f/7b/1f236cbdb1eb16a3faf9f7e6c18fa27b.gif" alt="">
那这种情况下其实我们也有优化手段。针对这个场我们实际上是对Canvas应用一个全局的blur滤镜把绘制的所有元素都变得模糊所以我们完全没必要对每个元素应用滤镜而是可以采用类似后期处理通道的做法先将图形以不使用滤镜的方式绘制到一个离屏的Canvas上然后直接将这个离屏Canvas以图片方式绘制到要显示的画布上在这次绘制的时候采用滤镜。这样我们就把大量滤镜绘制的过程缩减为对一张图片使用一次滤镜了。大大减少了处理滤镜的次数之后效果立竿见影帧率立即回到了60fps。
那么具体实现的代码和效果我也列出来,你可以看一下。
```
ctx.filter = 'blur(5px)';
// 创建离屏的 Canvas
const ofc = new OffscreenCanvas(canvas.width, canvas.height);
const octx = ofc.getContext('2d');
function draw() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
octx.clearRect(0, 0, canvas.width, canvas.height);
// 将图形不应用滤镜绘制到离屏Canvas上
for(let i = 0; i &lt; COUNT; i++) {
const shape = shapes[Math.floor(Math.random() * shapeTypes.length)];
const x = Math.random() * canvas.width;
const y = Math.random() * canvas.height;
octx.drawImage(shape, x, y);
}
// 再将离屏Canvas图像绘制到画布上这一次绘制采用了滤镜
ctx.drawImage(ofc, 0, 0);
requestAnimationFrame(draw);
}
draw();
```
<img src="https://static001.geekbang.org/resource/image/63/5b/63dcf4f363561cb745yy7075b418985b.gif" alt="">
当然这种优化滤镜的方式只有当我们要对画布上绘制的所有图形都采用同一种滤镜的时候才有效。不过如果有部分图形采用相同的滤镜而且它们是连续绘制的我们也可以采用类似的办法把这部分图形绘制到离屏Canvas上之后再将图像应用滤镜并绘制回画布。这样也能够减少滤镜的处理次数明显提升性能。总之想要达到比较好的性能我们要记住一个原则尽量合并图形应用相同滤镜的过程。
## 手段六:多线程渲染
到这里,我们说完了几种提升渲染性能的常见手段。不过,影响用户体验的不仅仅是渲染性能,有时候,我们还要对绘制的内容进行交互,而如果渲染过程消耗了大量的时间,它也可能会阻塞其他的操作,比如对事件的响应。
遇到这种问题的时候以前我们会比较头疼甚至不得不降低渲染性能以减少CPU资源占用从而让交互行为不被阻塞。不过现在浏览器支持的Canvas可以在WebWorker中以单独的线程来渲染这样就可以避免对主线程的阻塞也不会影响用户交互行为了。
那么具体怎么才能在Worker中绘制呢其实也很简单。我们在浏览器主线程中创建Worker然后将Canvas对象通过 transferControlToOffscreen 转成离屏Canvas对象发送给Worker线程去处理。
```
const canvas = document.querySelector('canvas');
const worker = new Worker('./random_shapes_worker.js');
const ofc = canvas.transferControlToOffscreen();
worker.postMessage({
canvas: ofc,
type: 'init',
}, [ofc]);
```
这样从使用上来说无论在Worker线程中还是在主线程中操作都没有太大的区别还能不阻塞浏览器主线程的任何操作。
我这里列出一部分核心代码完整的代码我放在GitHub仓库里你可以试着运行一下看看效果。
```
function draw(ctx, shapes) {
const canvas = ctx.canvas;
ctx.clearRect(0, 0, canvas.width, canvas.height);
for(let i = 0; i &lt; COUNT; i++) {
const shape = shapes[Math.floor(Math.random() * shapeTypes.length)];
const x = Math.random() * canvas.width;
const y = Math.random() * canvas.height;
ctx.drawImage(shape, x, y);
}
requestAnimationFrame(draw.bind(null, ctx, shapes));
}
self.addEventListener('message', (evt) =&gt; {
if(evt.data.type === 'init') {
const canvas = evt.data.canvas;
if(canvas) {
const ctx = canvas.getContext('2d');
const shapes = createCache();
draw(ctx, shapes);
}
}
});
```
## 要点总结
这节课我们讲了Canvas性能优化的6种手段其中前5种是针对渲染效率进行优化分别是优化Canvas指令、使用缓存、分层渲染、局部重绘以及针对滤镜的优化。最后一种是通过多线程来优化计算的性能让计算过程能够并行执行不会阻塞浏览器的UI。下面我再带你一起梳理一下性能优化的原则。
首先我们在绘制图形时用越简单的绘图指令来绘制渲染的效率就越高。所以我们要想办法减少Canvas绘图指令的数量比如用arc指令画圆来代替绘制边数很多的正多边形。
然后当我们大批量绘制有限的几种形状的图形时可以采用缓存将图形一次绘制后保存在离屏的Canvas中下一次绘制的时候我们直接绘制缓存的图片来取代原始的绘图指令也能大大提升性能。
可如果我们绘制的元素中只有一部分元素发生改变,我们就可以采用分层渲染,将变化的元素绘制在一个图层,剩下的元素绘制在另一个图层。这样每次只需要重新绘制变化元素所在的图层,大大减少绘制的图形数,从而显著提升了性能。
还有一种情况是如果Canvas只有部分区域发生变化那我们只需要刷新局部区域不需要刷新整个Canvas这样能显著降低消耗、提升性能。
还要注意的是一些Canvas滤镜渲染起来非常耗费性能所以我们可以对滤镜进行合并让多个元素只应用一次滤镜从而减少滤镜对性能的消耗。
最后除了优化渲染性能外我们还可以通过WebWork以多线程的手段优化计算性能以达到渲染不阻塞UI操作的目的。
## 小试牛刀
学会了使用多种优化手段之后,我们来尝试实现一个粒子效果吧!
具体效果是,我们要让小三角形以不同的角度和速度,由画布中心点向四周运动,同时小三角形自身也以随机的角速度旋转。
你可以尝试用两种方式来实现这个效果,分别是使用性能优化和不使用性能优化。在这两种情况下,你的电脑最多能支持同时绘制多少个小三角形?
我们今天学的这6种性能优化手段对你的工作是不是很有帮助那不妨就把这节课分享出去吧我们下节课再见
## 源码
[课程中详细示例代码GitHub仓库](https://github.com/akira-cn/graphics/tree/master/performance_canvas)
## 推荐阅读
[1] [AntV Canvas 局部渲染总结](https://juejin.im/post/5e7c13b351882535ea43e1a2)<br>
[2] [Speed up Your Canvas Operations with a Web WorkerWebWorker和OffscreenCanvas使用参考文档](https://developers.google.com/web/updates/2018/08/offscreen-canvas)

View File

@@ -0,0 +1,488 @@
<audio id="audio" title="30怎么给WebGL绘制加速" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/d5/12/d51b62b019a47160fyya1e4f35039312.mp3"></audio>
你好我是月影。这节课我们一起来讨论WebGL的性能优化。
WebGL因为能够直接操作GPU性能在各个图形系统中是最好的尤其是当渲染的元素特别多的时候WebGL的性能优势越明显。但是WebGL整体性能好并不意味着我们用WebGL就能写出高性能代码来。如果使用方式不当也不能充分发挥WebGL在性能方面的优势。
这节课我们就重点来说说怎么充分发挥WebGL的优势让它保持高性能。
## 尽量发挥GPU的优势
首先我们来想一个问题WebGL的优势是什么没错我强调过很多遍就是直接操作GPU。因此我们只有尽量发挥出GPU的优势才能让WebGL保持高性能。但这一点是很多习惯用SVG、Canvas的WebGL初学者最容易忽视的。
为了让你体会到发挥GPU优势的重要性我们先来看一个没有进行任何优化的绘图例子再对它进行优化。
### 常规绘图方式的性能瓶颈
假设我们要在一个画布上渲染3000个不同颜色的、位置随机的三角形并且让每个三角形的旋转角度也随机。
常规的实现方法当然是用JavaScript来创建随机三角形的顶点然后依次渲染。我在创建随机三角形顶点的时候是使用向量角度旋转的方法创建了正三角形我想这个方法你应该也不会陌生。
```
function randomTriangle(x = 0, y = 0, rotation = 0.0, radius = 0.1) {
const a = rotation,
b = a + 2 * Math.PI / 3,
c = a + 4 * Math.PI / 3;
return [
[x + radius * Math.sin(a), y + radius * Math.cos(a)],
[x + radius * Math.sin(b), y + radius * Math.cos(b)],
[x + radius * Math.sin(c), y + radius * Math.cos(c)],
];
}
```
然后我在下面代码的for循环中依次渲染每个三角形。
```
const COUNT = 3000;
function render() {
for(let i = 0; i &lt; COUNT; i++) {
const x = 2 * Math.random() - 1;
const y = 2 * Math.random() - 1;
const rotation = 2 * Math.PI * Math.random();
renderer.uniforms.u_color = [
Math.random(),
Math.random(),
Math.random(),
1];
const positions = randomTriangle(x, y, rotation);
renderer.setMeshData([{
positions,
}]);
renderer._draw();
}
requestAnimationFrame(render);
}
render();
```
这里我们只给着色器传入了一个颜色参数其他的运算都是在JavaScript中完成的所以对应的着色器代码非常简单。代码如下
```
// 顶点着色器
attribute vec2 a_vertexPosition;
void main() {
gl_Position = vec4(a_vertexPosition, 1, 1);
}
// 片元着色器
#ifdef GL_ES
precision highp float;
#endif
uniform vec4 u_color;
void main() {
gl_FragColor = u_color;
}
```
这样我们就完成了渲染3000个随机三角形的功能效果如下
<img src="https://static001.geekbang.org/resource/image/3a/d3/3a67da60549daebb2e460d2dc267efd3.gif" alt="">
你会发现这样实现的图形性能很一般因为3000个三角形渲染在普通笔记本电脑上只有20fps这大概和Canvas2D渲染出来的性能差不多可以说完全没能发挥出WebGL应有的优势。
那我们应该对哪些点进行优化,从而**尽量发挥出GPU的优势呢**
### 减少CPU计算次数
首先,我们可以不用生成这么多个三角形。根据前面学过的知识,我们可以创建一个正三角形,然后通过视图矩阵的变化来实现绘制多个三角形,而视图矩阵可以放在顶点着色器中计算。这样,我们就只要在渲染每个三角形的时候更新视图矩阵就行了。
具体来说就是,我们直接生成一个正三角形顶点,并设置数据到缓冲区。
```
const alpha = 2 * Math.PI / 3;
const beta = 2 * alpha;
renderer.setMeshData({
positions: [
[0, 0.1],
[0.1 * Math.sin(alpha), 0.1 * Math.cos(alpha)],
[0.1 * Math.sin(beta), 0.1 * Math.cos(beta)],
],
});
```
然后我们用随机坐标和角度更新每个三角形的modelMatrix数据。
```
const COUNT = 3000;
function render() {
for(let i = 0; i &lt; COUNT; i++) {
const x = 2 * Math.random() - 1;
const y = 2 * Math.random() - 1;
const rotation = 2 * Math.PI * Math.random();
renderer.uniforms.modelMatrix = [
Math.cos(rotation), -Math.sin(rotation), 0,
Math.sin(rotation), Math.cos(rotation), 0,
x, y, 1,
];
renderer.uniforms.u_color = [
Math.random(),
Math.random(),
Math.random(),
1];
renderer._draw();
}
requestAnimationFrame(render);
}
render();
```
而位置和角度的计算,我们放到顶点着色器内完成,代码如下:
```
attribute vec2 a_vertexPosition;
uniform mat3 modelMatrix;
void main() {
vec3 pos = modelMatrix * vec3(a_vertexPosition, 1);
gl_Position = vec4(pos, 1);
}
```
这么做了之后三角形渲染的fps会略有提升因为我们通过在顶点着色器中并行矩阵运算减少了顶点计算的次数。不过这个性能提升在最新的chrome浏览器下可能并不明显因为现在浏览器的JavaScript引擎的运算速度很快尽管将顶点计算放到顶点着色器中进行了性能差别也很微小。但不管怎么样这种方法依然是可以提升性能的。
### 静态批量绘制(多实例绘制)
那有没有办法更大程度地提升性能呢当然是有的。实际上对于需要重复绘制的图形最好的办法是使用批量绘制。重复图形的批量绘制在WebGL中也叫做**多实例绘制**Instanced Drawing它是一种减少绘制次数的技术。
在WebGL中一个几何图形一般需要一次渲染如果我们要绘制多个图形的话因为每个图形的顶点、颜色、位置等属性都不一样所以我们只能一一渲染不能一起渲染。但是如果几何图形的顶点数据都相同颜色、位置等属性就都可以在着色器计算那么我们就可以使用WebGL支持的多实例绘制方式一次性地把所有的图形都渲染出来。
多实例绘制的代码其实我们在第28课里已经见过了。这里我们再看一个例子帮你加深印象。
首先我们也是创建三角形顶点数据然后使用多实例绘制的方式传入数据。因为gl-renderer中已经封装好了多实例绘制的方法我们只需要传入instanceCount表示要绘制的图形数量即可。在原生的WebGL中使用多实例绘制会稍微复杂一点我们一般不会这么做但如果你想要尝试一下可以参考[这篇文章](https://www.jianshu.com/p/d40a8b38adfe)。
使用多实例绘制的代码如下:
```
const alpha = 2 * Math.PI / 3;
const beta = 2 * alpha;
const COUNT = 3000;
renderer.setMeshData({
positions: [
[0, 0.1],
[0.1 * Math.sin(alpha), 0.1 * Math.cos(alpha)],
[0.1 * Math.sin(beta), 0.1 * Math.cos(beta)],
],
instanceCount: COUNT,
attributes: {
id: {data: [...new Array(COUNT).keys()], divisor: 1},
},
});
```
这样我们就只需要每帧渲染一次就可以了。为了能在顶点着色器中完成图形的位置和颜色计算我们传入了时间uTime参数。代码如下
```
function render(t) {
renderer.uniforms.uTime = t;
renderer.render();
requestAnimationFrame(render);
}
render(0);
```
对应的顶点着色器如下:
```
attribute vec2 a_vertexPosition;
attribute float id;
uniform float uTime;
highp float random(vec2 co) {
highp float a = 12.9898;
highp float b = 78.233;
highp float c = 43758.5453;
highp float dt= dot(co.xy ,vec2(a,b));
highp float sn= mod(dt,3.14);
return fract(sin(sn) * c);
}
varying vec3 vColor;
void main() {
float t = id / 10000.0;
float alpha = 6.28 * random(vec2(uTime, 2.0 + t));
float c = cos(alpha);
float s = sin(alpha);
mat3 modelMatrix = mat3(
c, -s, 0,
s, c, 0,
2.0 * random(vec2(uTime, t)) - 1.0, 2.0 * random(vec2(uTime, 1.0 + t)) - 1.0, 1
);
vec3 pos = modelMatrix * vec3(a_vertexPosition, 1);
vColor = vec3(
random(vec2(uTime, 4.0 + t)),
random(vec2(uTime, 5.0 + t)),
random(vec2(uTime, 6.0 + t))
);
gl_Position = vec4(pos, 1);
}
```
我们这么做了之后每一帧的实际渲染次数即WebGL执行drawElements的次数从原来的3000减少到了只有1次而且计算都放到着色器里利用GPU并行处理了因此性能提升了3000倍。而且现在不要说3000哪怕是6000个三角形帧率都可以轻松达到60fps了是不是很厉害
### 动态批量绘制
可是,我又要给你泼一盆冷水了。虽然在绘制大量图形的时候,使用多实例绘制是一种非常好的方式,但是多实例渲染也有局限性,那就是只能在绘制相同的图形时使用。
不过如果是绘制不同的几何图形只要它们使用同样的着色器程序而且没有改变uniform变量我们也还是可以将顶点数据先合并再渲染以减少渲染次数。
这么说你可能还不太理解,我们一起来看一个例子。假设,我们现在不只显示正三角形,而是显示随机的正三角形、正方形和正五边形。最常规的实现方式和前面显示随机正三角形的例子类似,我们只要修改一下顶点生成的函数,根据不同的边数生成对应的正多边形就可以了。代码如下:
```
function randomShape(x = 0, y = 0, edges = 3, rotation = 0.0, radius = 0.1) {
const a0 = rotation;
const delta = 2 * Math.PI / edges;
const positions = [];
const cells = [];
for(let i = 0; i &lt; edges; i++) {
const angle = a0 + i * delta;
positions.push([x + radius * Math.sin(angle), y + radius * Math.cos(angle)]);
if(i &gt; 0 &amp;&amp; i &lt; edges - 1) {
cells.push([0, i, i + 1]);
}
}
return {positions, cells};
}
```
这样,我们就可以随机生成三、四、五、六边形,代码如下:
```
const {positions, cells} = randomShape(x, y, 3 + Math.floor(4 * Math.random()), rotation);
renderer.setMeshData([{
positions,
cells,
}]);
```
不过这个例子的性能就更差了渲染完3000个图形之后只有大概5fps。当然这是正常的因为正四边形、正五边形、正六边形每个分别要用2、3、4个三角形所以虽然要绘制3000个图形但我们实际绘制的三角形数量要远多于3000个。
而且,因为这些图形的形状不同,所以我们就不能使用多实例绘制的方式了。这个时候,我们又该如何优化呢?
我们依然可以将顶点合并起来绘制。因为每个图形都是由顶点positions和索引cells构成的所以我们可以批量创建图形将这些图形的顶点和索引全部合并起来。
```
function createShapes(count) {
const positions = new Float32Array(count * 6 * 3); // 最多6边形
const cells = new Int16Array(count * 4 * 3); // 索引数等于3倍顶点数-2
let offset = 0;
let cellsOffset = 0;
for(let i = 0; i &lt; count; i++) {
const edges = 3 + Math.floor(4 * Math.random());
const delta = 2 * Math.PI / edges;
for(let j = 0; j &lt; edges; j++) {
const angle = j * delta;
positions.set([0.1 * Math.sin(angle), 0.1 * Math.cos(angle), i], (offset + j) * 3);
if(j &gt; 0 &amp;&amp; j &lt; edges - 1) {
cells.set([offset, offset + j, offset + j + 1], cellsOffset);
cellsOffset += 3;
}
}
offset += edges;
}
return {positions, cells};
}
```
如上面代码所示我们首先创建两个类型数组positions和cells我们可以假定所有的图形都是正六边形算出要创建的类型数组的总长度。注意这里我们用的是三维顶点而不是二维顶点这并不是说我们要绘制的图形是3D图形而是我们使用z轴来保存当前图形的id提供给着色器中的伪随机函数使用。
计算顶点的方式和前面一样都用的是向量旋转的方法。值得注意的是在计算索引的时候我们只要将之前已经算过的几何图形顶点总数记录下来保存到offset变量里从offset值开始计算就可以了。
最终createShapes函数会返回一个包含几万个顶点和索引的几何体数据然后我们将它一次性渲染出来就行了。
```
const {positions, cells} = createShapes(COUNT);
renderer.setMeshData([{
positions,
cells,
}]);
function render(t) {
renderer.uniforms.uTime = t;
renderer.render();
requestAnimationFrame(render);
}
render(0);
```
因为,对应的顶点着色器代码,与我们前面用多实例绘制的三角形例子差不多,只有一些微小的改动,所以你可以对比着看一下,加深理解。
```
attribute vec3 a_vertexPosition;
uniform float uTime;
highp float random(vec2 co) {
highp float a = 12.9898;
highp float b = 78.233;
highp float c = 43758.5453;
highp float dt= dot(co.xy ,vec2(a,b));
highp float sn= mod(dt,3.14);
return fract(sin(sn) * c);
}
varying vec3 vColor;
void main() {
vec2 pos = a_vertexPosition.xy;
float t = a_vertexPosition.z / 10000.0;
float alpha = 6.28 * random(vec2(uTime, 2.0 + t));
float c = cos(alpha);
float s = sin(alpha);
mat3 modelMatrix = mat3(
c, -s, 0,
s, c, 0,
2.0 * random(vec2(uTime, t)) - 1.0, 2.0 * random(vec2(uTime, 1.0 + t)) - 1.0, 1
);
vColor = vec3(
random(vec2(uTime, 4.0 + t)),
random(vec2(uTime, 5.0 + t)),
random(vec2(uTime, 6.0 + t))
);
gl_Position = vec4(modelMatrix * vec3(pos, 1), 1);
}
```
采用动态批量绘制之后之前不到5fps的帧率就被我们轻松提升到了60fps。
<img src="https://static001.geekbang.org/resource/image/31/d5/31c369d5441acb07c3724b1d0daa91d5.jpg" alt="">
批量渲染几乎是WebGL绘制最大的优化手段因为它充分发挥了GPU的优势所以能极大地提升性能。因此在实际的WebGL项目中如果我们遇到性能瓶颈第一步就是要看看绘制的几何图形有哪些是可以批量渲染的如果能批量渲染的要尽量采用批量渲染以减少一帧中的绘制次数。
不过批量渲染也有局限性如果我们绘制的图形必须要用到不同的WebGLProgram或者每个图形要用到不同的uniform变量那么它们就无法合并渲染。因此我们在设计程序的时候要尽量避免WebGLProgram切换以及uniform的修改。
另外在前面两个例子中我们将id传入着色器然后根据id在着色器中用伪随机函数计算位置和颜色。这样的好处自然是渲染起来特别快但坏处是这些数据是在着色器中计算出来的如果我们想从JavaScript中拿到一些有用信息比如图形的位置、颜色等等就很难拿到了。
因此如果业务中需要用到这些信息我们就不能将它们放在着色器中计算。当然我们可以通过JavaScript来计算位置和颜色信息然后把它们写到attribute中。不过这样的话我们使用的内存消耗就会增加一些而且用JavaScript计算这些值的过程会比在着色器中略慢。当然这也是因为项目需求不得不做出的选择。
## 其他优化手段
好了对性能影响最大的批量绘制我们讲完了。其实还有两个因素对性能也有影响分别是透明与反锯齿和Shader效率。下面我也简单介绍一下。由于这些因素影响性能的原理相对比较简单我就不举例来说了你可以自己实践一下来加深理解。
### 透明度与反锯齿
首先是透明与反锯齿。在WebGL中我们要处理半透明图形可以开启混合模式Blending Mode让透明度生效。只有这样WebGL才会根据Alpha通道值和图形的层叠关系正确渲染并合成出叠加的颜色值。开启混合模式的代码如下
```
gl.enable(gl.BLEND);
```
不过,混合颜色本身有计算量,所以开启混合模式会造成一定的性能开销。因此,如果不需要处理半透明图形,我们尽量不开启混合模式,这样性能好就会更好一些。
此外WebGL本身对图形有反锯齿的优化反锯齿可以避免图形边缘在绘制时出现锯齿当然反锯齿本身也会带来性能开销。因此如果对反锯齿的要求不高我们在获取WebGL上下文时关闭反锯齿设置也能减少开销、提升渲染性能。
```
const gl = canvas.getContext('webgl', {antiAlias: false}); //不消除反锯齿
```
### Shader的效率
最后Shader的效率也是我们在使用WebGL时需要注意的。我们前面说过为了尽可能合并数据动态批量绘制图形我们要求图形尽量使用同一个WebGLProgram并且避免在绘制过程中切换WebGLProgram。
但如果不同图形的绘制都使用同一个WebGLProgram这也会造成着色器本身的代码逻辑复杂从而影响Shder的效率。最好的解决办法就是尽可能拆分不同的着色器代码然后在绘制过程中根据不同元素进行切换。所以批量绘制和简化WebGLProgram是一对矛盾我们只能对两者进行取舍尽可能让性能达到最优。
另外shader代码不同于常规的JavaScript代码它最大的特性是并行计算因此处理逻辑的过程与普通的代码不同。
那不同在哪儿呢我们先来看一个常规的JavaScript代码。
```
if(Math.random() &gt; 0.5) {
do something
} else {
do somthing else
}
```
我们都知道如果if语句中的条件值为true那么第一个分支被执行否则第二个分支被执行这两个分支是不能同时被执行的。
但如果是Shader中的代码情况就完全不同了。
```
if(random(st) &gt; 0.5) {
gl_FragColor = vec4(1)
} else {
gl_FragColor = vec4(0)
}
```
无论是if还是else分支在glsl中都会被执行最终的值则根据条件表达式结果不同取不同分支计算的结果。
之所以会这样就是因为GPU是并行计算的也就是说并行执行大量glsl程序但是每个子程序并不知道其他子程序的执行结果所以最优的办法就是事先计算好if和else分支中的结果再根据不同子程序的条件返回对应的结果。因此if语句必然要同时执行两个分支但这样就会造成性能上一定的损耗解决这个问题的办法是尽可能不用if语句。比如对上面的代码我们不用if语句而是用step函数来解决问题这样性能就会好一些。代码如下
```
gl_FragColor = vec4(1) * step(random(st), 0.5);
```
此外,一些耗时的计算,比如开平方、反正切、反余弦等等,我们的优化原则也是能避免就尽可能避免,多使用简单的加法和乘法,这样就能保证着色器的高效率运行了。
## 要点总结
今天我们重点讲了优化WebGL绘制性能的核心原则。
虽然WebGL是图形系统中渲染性能最高的但如果我们不够了解GPU不对它进行有效的优化就不能很好地发挥出WebGL的高性能优势。
用一句话总结WebGL的性能优化原则就是尽量发挥出GPU的优势。核心原则有两个首先我们尽量减少CPU计算次数把能放在GPU中计算的部分放在GPU中并行计算其次也是更重要的我们应该减少每一帧的绘制次数。
对应的优化方法也有两个一是如果我们要绘制大量相同的图形可以利用多实例渲染来实现静态批量绘制二是如果绘制的图形不同但是采用的WebGL程序相同、以及uniform的值没有改变那我们可以人为合并顶点并进行渲染。减少绘制次数一般来说对性能会有比较明显的提升。
除此之外我们还可以在不需要处理透明度的时候不启用混合模式在不需要抗锯齿的时候关闭抗锯齿功能它们都能减少性能开销。以及我们还要注意Shader的效率尽量用函数代替分支避免一些耗时的计算多使用简单的加法和乘法这样能够保证着色器高效运行。
总的来说性能优化是一个非常复杂的问题我们应该结合实际项目的需求、数据的特征、技术方案等等综合考虑最终才能得出最适合的方案。在实际项目中无论你是直接用原生的WebGL还是使用OGL、SpriteJS或者ThreeJS大体的优化思路肯定离不开我前面总结的这些点。但怎么既恰到好处的优化又保持性能与产品功能、开发效率以及扩展性的平衡就需要我们通不断积累项目经验才能慢慢做到最好啦。
## 小试牛刀
在前面的例子中我们把位置和颜色的计算都放在了着色器中。这有利有弊如果让你来重构代码你能做到既兼顾性能又能满足我们从JavaScript中拿到几何体位置和颜色的需求吗如果可以就快把你的解决方案写好分享出来吧。
欢迎在留言区和我讨论,分享你的答案和思考,也欢迎你把这节课分享给你的朋友,我们下节课见!
## 源码
[课程中完整示例代码](https://github.com/akira-cn/graphics/tree/master/performance-webgl)
## 推荐阅读
[WebGL2系列之实例数组(Instanced Arrays)](https://www.jianshu.com/p/d40a8b38adfe)