18 章 动画与 Canvas 图形


本章内容
 使用 requestAnimationFrame
 理解元素
 绘制简单 2D 图形
 使用 WebGL 绘制 3D 图形

1 使用 requestAnimationFrame

这个方法会告诉浏览器要执行动画了,于是浏览器可以通过最优方式确定重绘的时序。

1.1 早期定时动画

以前,在 JavaScript 中创建动画基本上就是使用 setInterval()来控制动画的执行。

(function() { 
 function updateAnimations() { 
 doAnimation1(); 
 doAnimation2(); 
 // 其他任务
 } 
 setInterval(updateAnimations, 100); 
})();

这种定时动画的问题在于无法准确知晓循环之间的延时。定时间隔必须足够短,这样才能让不同的动画类型都能平滑顺畅,但又要足够长,以便产生浏览器可以渲染出来的变化。无论 setInterval()还是 setTimeout()都是不能保证时间精度的。作为第二个参数的延时只能保证何时会把代码添加到浏览器的任务队列,不能保证添加到队列就会立即运行。如果队列前面还有其他任务,那么就要等这些任务执行完再执行。

1.2 时间间隔的问题

知道何时绘制下一帧是创造平滑动画的关键。setInterval()和setTimeout()的不精确是个大问题。
浏览器自身计时器的精度让这个问题雪上加霜。浏览器的计时器精度不足毫秒。以下是几个浏览器计时器的精度情况:
 IE8 及更早版本的计时器精度为 15.625 毫秒;
 IE9 及更晚版本的计时器精度为 4 毫秒;
 Firefox 和 Safari 的计时器精度为约 10 毫秒;
 Chrome 的计时器精度为 4 毫秒。
IE9 之前版本的计时器精度是 15.625 毫秒,意味着 0~15 范围内的任何值最终要么是 0,要么是 15,不可能是别的数。

1.3 requestAnimationFrame

requestAnimationFrame()方法用以通知浏览器某些 JavaScript 代码要执行动画了。

function updateProgress() { 
 var div = document.getElementById("status"); 
 div.style.width = (parseInt(div.style.width, 10) + 5) + "%"; 
 if (div.style.left != "100%") { 
 requestAnimationFrame(updateProgress); 
 } 
} 
requestAnimationFrame(updateProgress);

传给 requestAnimationFrame()的函数实际上可以接收一个参数,此参数是一个 DOMHighResTimeStamp 的实例(比如performance.now()返回的值),表示下次重绘的时间。这一点非常重要:requestAnimationFrame()实际上把重绘任务安排在了未来一个已知的时间点上,而且通过这个参数告诉了开发者。

1.4 cancelAnimationFrame

setTimeout()类似,requestAnimationFrame()也返回一个请求 ID,可以用于通过另一个方法 cancelAnimationFrame()来取消重绘任务。下面的例子展示了刚把一个任务加入队列又立即将其取消:

let requestID = window.requestAnimationFrame(() => { 
 console.log('Repaint!'); 
}); 
window.cancelAnimationFrame(requestID);

1.5 通过 requestAnimationFrame 节流

支持这个方法的浏览器实际上会暴露出作为钩子的回调队列。所谓钩子(hook),就是浏览器在执行下一次重绘之前的一个点。这个回调队列是一个可修改的函数列表,包含应该在重绘之前调用的函数。每次调用requestAnimationFrame()都会在队列上推入一个回调函数,队列的长度没有限制。
通过 requestAnimationFrame()递归地向队列中加入回调函数,可以保证每次重绘最多只调用一次回调函数。这是一个非常好的节流工具。在频繁执行影响页面外观的代码时(比如滚动事件监听器),可以利用这个回调队列进行节流。

原生实现滚动事件监听器:

function expensiveOperation() { 
 console.log('Invoked at', Date.now()); 
}
window.addEventListener('scroll', () => { 
 expensiveOperation(); 
});

如果想把事件处理程序的调用限制在每次重绘前发生,那么可以像这样下面把它封装到 requestAnimationFrame()调用中:

function expensiveOperation() { 
 console.log('Invoked at', Date.now()); 
} 
window.addEventListener('scroll', () => { 
 window.requestAnimationFrame(expensiveOperation); 
});

这样会把所有回调的执行集中在重绘钩子,但不会过滤掉每次重绘的多余调用。此时,定义一个标志变量,由回调设置其开关状态,就可以将多余的调用屏蔽:

let enqueued = false; 
function expensiveOperation() { 
 console.log('Invoked at', Date.now()); 
 enqueued = false; 
} 
window.addEventListener('scroll', () => { 
 if (!enqueued) { 
 enqueued = true; 
 window.requestAnimationFrame(expensiveOperation); 
 } 
});

因为重绘是非常频繁的操作,所以这还算不上真正的节流。更好的办法是配合使用一个计时器来限制操作执行的频率。这样,计时器可以限制实际的操作执行间隔,而 requestAnimationFrame 控制在浏览器的哪个渲染周期中执行。下面的例子可以将回调限制为不超过 50 毫秒执行一次:

let enabled = true; 
function expensiveOperation() { 
 console.log('Invoked at', Date.now()); 
} 
window.addEventListener('scroll', () => { 
 if (enabled) { 
 enabled = false; 
 window.requestAnimationFrame(expensiveOperation); 
 window.setTimeout(() => enabled = true, 50); 
 } 
});

2 基本的画布功能

创建canvas元素时至少要设置其 width 和 height 属性,这样才能告诉浏览器在多大面积上绘图。出现在开始和结束标签之间的内容是后备数据,会在浏览器不支持canvas元素时显示。比如:

<canvas id="drawing" width="200" height="200">A drawing of something.</canvas>
// 确保浏览器支持<canvas> 
if (drawing.getContext) {
 let context = drawing.getContext("2d");   // 获取对绘图上下文的引用,表示要获取 2D 上下文对象
 // 其他代码
}

可以使用 toDataURL()方法导出元素上的图像。这个方法接收一个参数:要生成图像的 MIME 类型(与用来创建图形的上下文无关)。例如,要从画布上导出一张 PNG 格式的图片,可以这样做:

let drawing = document.getElementById("drawing"); 
// 确保浏览器支持<canvas> 
if (drawing.getContext) { 
 // 取得图像的数据 URI 
 let imgURI = drawing.toDataURL("image/png"); 
 // 显示图片
 let image = document.createElement("img"); 
 image.src = imgURI; 
 document.body.appendChild(image); 
}

3 2D 绘图上下文

3.1 填充和描边

let drawing = document.getElementById("drawing"); 
// 确保浏览器支持<canvas> 
if (drawing.getContext) { 
 let context = drawing.getContext("2d"); 
 context.strokeStyle = "red"; 
 context.fillStyle = "#0000ff"; 
}

3.2 绘制矩形

 let context = drawing.getContext("2d"); 
 // 绘制半透明蓝色矩形
 context.fillStyle = "rgba(0,0,255,0.5)"; 
 context.fillRect(30, 30, 50, 50);

描边宽度由 lineWidth 属性控制,它可以是任意整数值。类似地,lineCap 属性控制线条端点的形状[“butt”(平头)、“round”(出圆头)或"square"(出方头)],而 lineJoin属性控制线条交点的形状[“round”(圆转)、“bevel”(取平)或"miter"(出尖)]

使用 clearRect()方法可以擦除画布中某个区域。该方法用于把绘图上下文中的某个区域变透明。

 context.clearRect(40, 40, 10, 10);

3.3 绘制路径

beginPath()方法以表示要开始绘制新路径。然后,再调用下列方法来绘制路径。
 arc(x, y, radius, startAngle, endAngle, counterclockwise):以坐标(x, y)为圆心,以 radius 为半径绘制一条弧线,起始角度为 startAngle,结束角度为 endAngle(都是弧度)。最后一个参数 counterclockwise 表示是否逆时针计算起始角度和结束角度(默认为顺时针)。
 arcTo(x1, y1, x2, y2, radius):以给定半径 radius,经由(x1, y1)绘制一条从上一点到(x2, y2)的弧线。
 bezierCurveTo(c1x, c1y, c2x, c2y, x, y):以(c1x, c1y)和(c2x, c2y)为控制点,绘制一条从上一点到(x, y)的弧线(三次贝塞尔曲线)。
 lineTo(x, y):绘制一条从上一点到(x, y)的直线。
 moveTo(x, y):不绘制线条,只把绘制光标移动到(x, y)。
 quadraticCurveTo(cx, cy, x, y):以(cx, cy)为控制点,绘制一条从上一点到(x, y)的弧线(二次贝塞尔曲线)。
 rect(x, y, width, height):以给定宽度和高度在坐标点(x, y)绘制一个矩形。这个方法与 strokeRect()和 fillRect()的区别在于,它创建的是一条路径,而不是独立的图形。

3.4 绘制文本

2D绘图上下文还提供了绘制文本的方法,即fillText()和 strokeText()。
 font:以 CSS 语法指定的字体样式、大小、字体族等,比如"10px Arial"。
 textAlign:指定文本的对齐方式,可能的值包括"start"、“end”、“left”、“right"和"center”。推荐使用"start"和"end",不使用"left"和"right",因为前者无论在从左到右书写的语言还是从右到左书写的语言中含义都更明确。
 textBaseLine :指定文本的基线,可能的值包括 “top” 、 “hanging” 、 “middle” 、“alphabetic”、“ideographic"和"bottom”。

3.5 变换

以下方法可用于改变绘制上下文的变换矩阵。
 rotate(angle):围绕原点把图像旋转 angle 弧度。
 scale(scaleX, scaleY):通过在 x 轴乘以 scaleX、在 y 轴乘以 scaleY 来缩放图像。scaleX和 scaleY 的默认值都是 1.0。
 translate(x, y):把原点移动到(x, y)。执行这个操作后,坐标(0, 0)就会变成(x, y)。
 transform(m1_1, m1_2, m2_1, m2_2, dx, dy):通过矩阵乘法直接修改矩阵。
 setTransform(m1_1, m1_2, m2_1, m2_2, dx, dy):把矩阵重置为默认值,再以传入的参数调用 transform()。

3.6 绘制图像

2D 绘图上下文内置支持操作图像。如果想把现有图像绘制到画布上,可以使用 drawImage()方法。

let image = document.images[0]; 
context.drawImage(image, 10, 10);

3.7 阴影

2D 上下文可以根据以下属性的值自动为已有形状或路径生成阴影。
 shadowColor:CSS 颜色值,表示要绘制的阴影颜色,默认为黑色。
 shadowOffsetX:阴影相对于形状或路径的 x 坐标的偏移量,默认为 0。
 shadowOffsetY:阴影相对于形状或路径的 y 坐标的偏移量,默认为 0。
 shadowBlur:像素,表示阴影的模糊量。默认值为 0,表示不模糊。

3.8 渐变

let gradient = context.createLinearGradient(30, 30, 70, 70); 
gradient.addColorStop(0, "white"); 
gradient.addColorStop(1, "black");

3.9 图案

要创建新图案,可以调用 createPattern()方法并传入两个参数:一个 HTML 元素和一个表示该如何重复图像的字符串。第二个参数的值与 CSS 的background-repeat 属性是一样的,包括"repeat"、“repeat-x”、“repeat-y"和"no-repeat”。比如:

let image = document.images[0], 
 pattern = context.createPattern(image, "repeat"); 
// 绘制矩形
context.fillStyle = pattern; 
context.fillRect(10, 10, 150, 150);

3.10 图像数据

使用 getImageData()方法获取原始图像数据。例如,要从(10, 5)开始取得 50 像素宽、50 像素高的区域对应的数据,可以这样写:

let imageData = context.getImageData(10, 5, 50, 50);

3.11 合成

2D上下文中绘制的所有内容都会应用两个属性:globalAlpha 和 globalComposition Operation,其中,globalAlpha 属性是一个范围在 0~1 的值(包括 0 和 1),用于指定所有绘制内容的透明度,默认值为 0。
globalCompositionOperation 属性表示新绘制的形状如何与上下文中已有的形状融合。这个属性是一个字符串,可以取下列值。
 source-over:默认值,新图形绘制在原有图形上面。
 source-in:新图形只绘制出与原有图形重叠的部分,画布上其余部分全部透明。
 source-out:新图形只绘制出不与原有图形重叠的部分,画布上其余部分全部透明。
 source-atop:新图形只绘制出与原有图形重叠的部分,原有图形不受影响。
 destination-over:新图形绘制在原有图形下面,重叠部分只有原图形透明像素下的部分可见。
 destination-in:新图形绘制在原有图形下面,画布上只剩下二者重叠的部分,其余部分完全透明。
 destination-out:新图形与原有图形重叠的部分完全透明,原图形其余部分不受影响。
 destination-atop:新图形绘制在原有图形下面,原有图形与新图形不重叠的部分完全透明。
 lighter:新图形与原有图形重叠部分的像素值相加,使该部分变亮。
 copy:新图形将擦除并完全取代原有图形。
 xor:新图形与原有图形重叠部分的像素执行“异或”计算。

4 WebGL

WebGL 是画布的 3D 上下文。

4.1 WebGL 上下文

WebGL 2.0 上下文的名字叫"webgl2",WebGL 1.0 上下文的名字叫"webgl1"。如果浏览器不支持 WebGL,则尝试访问 WebGL 上下文会返回 null。

 let gl = drawing.getContext("webgl"); 
 if (gl){ 
 // 使用 WebGL 
 }

4.2 WebGL 基础

可以在调用 getContext()取得 WebGL 上下文时指定一些选项。这些选项通过一个参数对象传入,选项就是参数对象的一个或多个属性。
 alpha:布尔值,表示是否为上下文创建透明通道缓冲区,默认为 true。
 depth:布尔值,表示是否使用 16 位深缓冲区,默认为 true。
 stencil:布尔值,表示是否使用 8 位模板缓冲区,默认为 false。
 antialias:布尔值,表示是否使用默认机制执行抗锯齿操作,默认为 true。
 premultipliedAlpha:布尔值,表示绘图缓冲区是否预乘透明度值,默认为 true。
 preserveDrawingBuffer:布尔值,表示绘图完成后是否保留绘图缓冲区,默认为 false。建议在充分了解这个选项的作用后再自行修改,因为这可能会影响性能。
如果调用 getContext()不能创建 WebGL 上下文,某些浏览器就会抛出错误。为此,最好把这个方法调用包装在 try/catch 块中:

// 确保浏览器支持<canvas> 
if (drawing.getContext) { 
 try { 
 gl = drawing.getContext("webgl", { alpha: false }); 
 } catch (ex) { 
 // 什么也不做
 }
 if (gl) { 
 // 使用 WebGL 
 } else { 
 alert("WebGL context could not be created."); 
 } 
}

1. 常量

在 WebGL 中,context 对象上的常量则不包含 GL_前缀。例如,GL_COLOR_BUFFER_BIT 常量在WebGL 中要这样访问 gl.COLOR_BUFFER_BIT。

2. 方法命名

表示数据类型的字符串(“f”表示浮点数,“i”表示整数)在后。比如,gl.uniform4f()的意思是需要 4 个浮点数值参数,而 gl.uniform3i()表示需要 3 个整数值参数。

3. 准备绘图

准备使用 WebGL 上下文之前,通常需要先指定一种实心颜色清除canvas。为此,要调用clearColor()方法并传入 4 个参数,分别表示红、绿、蓝和透明度值。每个参数必须是 0~1 范围内的值,表示各个组件在最终颜色的强度。比如:

gl.clearColor(0, 0, 0, 1); // 把清理颜色缓冲区的值设置为黑色
gl.clear(gl.COLOR_BUFFER_BIT); // 告诉 WebGL 使用之前定义的颜色填充画布

4. 视口与坐标

gl.viewport(0, 0, drawing.width, drawing.height); // 表示要使用整个<canvas>元素
// 视口是<canvas> 左下角四分之一区域
gl.viewport(0, 0, drawing.width/2, drawing.height/2); 
// 视口是<canvas> 左上角四分之一区域
gl.viewport(0, drawing.height/2, drawing.width/2, drawing.height/2); 
// 视口是<canvas> 右下角四分之一区域
gl.viewport(drawing.width/2, 0, drawing.width/2, drawing.height/2);

定义视口的坐标系统与视口中的坐标系统不一样。在视口中,坐标原点(0, 0)是视口的中心点。左下角是(–1, –1),右上角是(1, 1),如下图所示。
在这里插入图片描述

5. 缓冲区

在 JavaScript 中,顶点信息保存在定型数组中。要使用这些信息,必须先把它们转换为 WebGL 缓冲区。创建缓冲区要调用 gl.createBuffer()方法,并使用 gl.bindBuffer()方法将缓冲区绑定到WebGL 上下文。绑定之后,就可以用数据填充缓冲区了。比如:

let buffer = gl.createBuffer(); 
gl.bindBuffer(gl.ARRAY_BUFFER, buffer); 
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([0, 0.5, 1]), gl.STATIC_DRAW);

gl.bufferData()方法的最后一个参数表示如何使用缓冲区。这个参数可以是以下常量值。
 gl.STATIC_DRAW:数据加载一次,可以在多次绘制中使用。
 gl.STREAM_DRAW:数据加载一次,只能在几次绘制中使用。
 gl.DYNAMIC_DRAW:数据可以重复修改,在多次绘制中使用。
缓冲区会一直驻留在内存中,直到页面卸载。如果不再需要缓冲区,那么最好调用 gl.deleteBuffer()方法释放其占用的内存:

gl.deleteBuffer(buffer);

6. 错误

与 JavaScript 多数情况下不同的是,在 WebGL 操作中通常不会抛出错误。必须在调用可能失败的方法后,调用 gl.getError()方法。这个方法返回一个常量,表示发生的错误类型。下面列出了这些常量。
 gl.NO_ERROR:上一次操作没有发生错误(0 值)。
 gl.INVALID_ENUM:上一次操作没有传入 WebGL 预定义的常量。
 gl.INVALID_VALUE:上一次操作需要无符号数值,但是传入了负数。
 gl.INVALID_OPERATION:上一次操作在当前状态下无法完成。
 gl.OUT_OF_MEMORY:上一次操作因内存不足而无法完成。
 gl.CONTEXT_LOST_WEBGL:上一次操作因外部事件(如设备掉电)而丢失了 WebGL 上下文。

7. 着色器

着色器是 OpenGL 中的另一个概念。WebGL 中有两种着色器:顶点着色器和片段(或像素)着色器。顶点着色器用于把 3D 顶点转换为可以渲染的 2D 点。片段着色器用于计算绘制一个像素的正确颜色。使用 GLSL(OpenGL Shading Language)写的。

编写着色器

下面是一个简单的顶点着色器的例子:

// OpenGL 着色器语言
attribute vec2 aVertexPosition; 
void main() { 
 gl_Position = vec4(aVertexPosition, 0.0, 1.0); 
}
创建着色器程序

浏览器并不理解原生 GLSL 代码,通常可以使用带有自定义 type 属性的script元素把着色器代码包含在网页中。如果 type 属性无效,则浏览器不会解析script的内容,但这并不妨碍读写其中的内容:

<script type="x-webgl/x-vertex-shader" id="vertexShader"> 
attribute vec2 aVertexPosition; 
void main() { 
 gl_Position = vec4(aVertexPosition, 0.0, 1.0); 
} 
</script> 
<script type="x-webgl/x-fragment-shader" id="fragmentShader"> 
uniform vec4 uColor; 
void main() { 
 gl_FragColor = uColor; 
} 
</script>

然后可以使用 text 属性提取script元素的内容:

let vertexGlsl = document.getElementById("vertexShader").text, 
 fragmentGlsl = document.getElementById("fragmentShader").text;

有了 GLSL 字符串,下一步是创建 shader 对象。为此,需要调用 gl.createShader()方法,并传入想要创建的着色器类型( gl.VERTEX_SHADER 或 gl.FRAGMENT_SHADER )。然后,调用gl.shaderSource()方法把 GLSL 代码应用到着色器,再调用 gl.compileShader()编译着色器。下面是一个例子:

let vertexShader = gl.createShader(gl.VERTEX_SHADER); 
gl.shaderSource(vertexShader, vertexGlsl); 
gl.compileShader(vertexShader); 
let fragmentShader = gl.createShader(gl.FRAGMENT_SHADER); 
gl.shaderSource(fragmentShader, fragmentGlsl); 
gl.compileShader(fragmentShader);

通过以下代码把这两个对象链接到着色器程序:

let program = gl.createProgram(); // 创建了一个程序
gl.attachShader(program, vertexShader); // 用于添加着色器
gl.attachShader(program, fragmentShader); 
gl.linkProgram(program); // 着色器链接到了变量 program
gl.useProgram(program); // 让WebGL 上下文使用这个程序
给着色器传值

前面定义的每个着色器都需要传入一个值,才能完成工作。要给着色器传值,必须先找到要接收值的变量。对于 uniform 变量,可以调用 gl.getUniformLocation()方法。

let uColor = gl.getUniformLocation(program, "uColor"); 
gl.uniform4fv(uColor, [0, 0, 0, 1]);

5 小结

requestAnimationFrame 是简单但实用的工具,可以让 JavaScript 跟进浏览器渲染周期,从而更加有效地实现网页视觉动效。

HTML5 的canvas元素为 JavaScript 提供了动态创建图形的 API。这些图形需要使用特定上下文绘制,主要有两种。第一种是支持基本绘图操作的 2D 上下文:
 填充和描绘颜色及图案
 绘制矩形
 绘制路径
 绘制文本
 创建渐变和图案
第二种是 3D 上下文,也就是 WebGL。

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值