0%

JavaScript学习笔记(15)

《JavaScript高级程序设计》第十五章,使用canvas绘图。
我的体会是,WebGL比我以为的要更难些。


包括具备基本绘图能力的2D上下文和命名为WebGL的3D上下文。

基本用法

首先,设置元素的宽高来指定绘图区域大小;其次,通过canvasElem.getContext("2d")canvasElem.getContext("webgl")取得绘图上下文,这样方能绘图。

1
2
3
4
<canvas id="drawing" width="200" height="200">
<!-- 当浏览器不支持时的兜底信息 -->
A drawing of something
</canvas>
1
2
3
4
5
const drawing = document.getElementById("drawing");
if(drawing.getContext) {
const ctx = drawing.getContext("2d");
const gl = drawing.getContext("webgl");
}

* 一个神奇的方法
使用canvas.toDataURL()方法可以导出在canvas上绘制的图像,它接收图像的MIME类型格式作为参数。下例把画布上的内容导出为一个PNG格式图像。

1
2
3
4
5
6
7
const drawing = document.getElementById("drawing");
if(drawing.getContext) {
const imageURL = drawing.toDataURL("image/png"); // 取得图像的数据URL
const image = document.createElement("img"); // 显示图像
image.src = imageURL;
document.body.appendChild(image);
}

2D上下文

用2D上下文提供的方法,可以绘制简单的2D图形。下面内容就权当记录一下这些API吧~

填充和描边

就是给图像填充颜色/渐变/图像的fillStyle,以及给图形边缘画线的strokeStyle。这俩属性的默认值都是"#000000",可以指定任何格式的颜色(颜色名、十六进制码、rgb、rgba、hsl、hsla)。

1
2
3
const ctx = drawing.getContext("2d");
ctx.fillStyle = "#0000ff";
ctx.strokeStyle = "red";

画矩形

没想到矩形居然是唯一一种可以直接在2D上下文绘制的形状!有几个方法:fillRect()strokeRect()clearRect(),它们接收4个参数:x坐标、y坐标、宽度、高度。前面两个要分别配合fillStylestrokeStyle来使用。clearRect()则用于将画布上某个矩形区域变透明。

1
2
3
4
5
6
7
8
9
const ctx = drawing.getContext("2d");

// 绘制红色填充的矩形
ctx.fillStyle = "#ff0000";
ctx.fillRect(10, 10, 50, 50);

// 绘制红色描边的矩形
ctx.strokeStyle = "#ff0000";
ctx.strokeRect(10, 10, 50, 50);

画路径

通过路径可以创造出复杂的形状和线条。画路径的第一步是,调用beginPath()方法,表示要开始绘制新路径。接着,用下面这些方法来实际地画:

  • arc(x, y, radius, startAngle, endAngle, counterclockwise)
    (x, y)为圆心画一条半径为radius、起始和结束弧度为startAngleendAngle的弧线,最后一个参数是表示是否为逆时针的一个布尔值。
  • arcTo(x1, y1, x2, y2, radius)
    从上一点开始绘制一条弧线,并以radius为半径穿过(x1, y1),到(x2, y2)为止。
  • bezierCurveTo(c1x, c1y, c2x, c2y, x, y)
    从上一点开始绘制一条曲线,并以(c1x, c1y)(c2x, c2y)为控制点,到(x, y)为止。* bezier意为贝塞尔
  • 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)开始绘制一个宽为width高为height的矩形,这个方法绘制的是矩形路径,而非strokeRect()fillRect()绘制的形状。

创建完路径后,有几种选择。如果想把终点连回起点,可以用closePath();如果路径已完成,可以用fillStylefill()去填充颜色、用strokeStylestroke()去对路径描边;如果想在路径上创建一个剪切区域,可以用clip()

举一个画线的例子:

1
2
3
4
5
6
7
8
9
10
11
const ctx = drawing.getContext("2d");

ctx.beginPath(); // 开始路径
ctx.arc(100, 100, 99, 0, 2 * Math.PI, false); // 画外圆
ctx.moveTo(194, 100);
ctx.arc(100, 100, 94, 0, 2 * Math.PI, false); // 画内圆
ctx.moveTo(100, 100);
ctx.lineTo(100, 15); // 画分针
ctx.moveTo(100, 100);
ctx.lineTo(35, 100); // 画时针
ctx.stroke(); // 描边路径

由于路径使用得很频繁,所以有了一个叫isPointInPath(x, y)的方法,可以在路径被关闭之前确定画布上的某一点是否位于路径上。

写文本

主要用fillText()strokeText(),它们接收4个参数:文本字符串、x坐标、y坐标、可选的最大像素宽度(这个参数的作用是,当字符串长超过宽度时,会适当地横向压缩)。它们都以下面3个属性为基础(这3个属性都有默认值,因此不需要每次都重新设置):

  • font
    表示文本样式、大小、字体,如"10px Arial"
  • textAlign
    表示文本对齐方式,有5种取值:startendleftrightcenter。一般建议用start/end而非left/right,这样对LTR和RTL语言都比较友好。
  • textBaseline
    表示文本基线,有6种取值:tophangingmiddlealphabeticideographicbottom

有一些场景下,需要将文本控制在某一区域中,2D上下文提供了一个辅助方法measureText(),它可以根据fonttextAligntextBaseline计算处指定文本的大小。它将要绘制的文本作为参数,返回一个TextMetrics对象,目前这个对象只有个width属性,但之后还会增加的。

举个例子,假设我们想在140px的矩形区域里绘制文本,可以用下面的方法枚举来找到合适的字体大小:

1
2
3
4
5
6
7
8
9
10
var fontSize = 100;
ctx.font = fontSize + "px Arial";

while(ctx.measureText("Hello World").width > 140) {
fontSize--;
ctx.font = fontSize + "px Arial";
}

ctx.fillText("Hello World", 10, 10);
ctx.fillText(`Suitable font size is ${fontSize}px`, 10, 50);

变换

有几种变换:

  • rotate(angle)
    围绕原点旋转图像angle弧度。
  • scale(scaleX, scaleY)
    缩放图像,在x方向乘以scaleX,在y方向乘以scaleY,二者的默认值为1
  • translate(x, y)
    将坐标原点移动到(x, y)
  • transform(m11, m12, m21, m22, dx, dy)
    直接修改变换矩阵,将其乘以矩阵[m11 m12 dx, m21 m22 dy, 0 0 1]
  • setTransform(m11, m12, m21, m22, dx, dy)
    将变换矩阵重置为默认状态,然后再调用transform()

举个例子,像之前的绘制表针,如果把原点换到表盘中心,就会很方便,因为所有计算都会基于(0, 0)而非(100, 100)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const ctx = drawing.getContext("2d");

ctx.beginPath(); // 开始路径
ctx.arc(100, 100, 99, 0, 2 * Math.PI, false); // 画外圆
ctx.moveTo(194, 100);
ctx.arc(100, 100, 94, 0, 2 * Math.PI, false); // 画内圆

// ctx.moveTo(100, 100);
// ctx.lineTo(100, 15); // 画分针
// ctx.moveTo(100, 100);
// ctx.lineTo(35, 100); // 画时针

ctx.translate(100, 100); // 变换原点
ctx.moveTo(0, 0);
ctx.lineTo(0, -85); // 画分针
ctx.moveTo(0, 0);
ctx.lineTo(-65, 0); // 画时针

ctx.stroke(); // 描边路径

再基于上面的例子,演示一下当原点移到中心之后rotate()的好处,结果如下图:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const ctx = drawing.getContext("2d");

ctx.beginPath(); // 开始路径
ctx.arc(100, 100, 99, 0, 2 * Math.PI, false); // 画外圆
ctx.moveTo(194, 100);
ctx.arc(100, 100, 94, 0, 2 * Math.PI, false); // 画内圆
ctx.translate(100, 100); // 变换原点

ctx.rotate(1); // 旋转表针

ctx.moveTo(0, 0);
ctx.lineTo(0, -85); // 画分针
ctx.moveTo(0, 0);
ctx.lineTo(-65, 0); // 画时针

ctx.stroke(); // 描边路径

跟踪状态

无论是变换还是fillStylestrokeStyle等属性,都会在当前上下文中一直有效,除非再对上下文进行什么修改。虽然没法把上下文重置回默认值,但是有两个方法可以跟踪上下文状态变化:save()restore()。当知道当前的所有属性或变换,会在将来有用时,可以用save()将当前的所有设置都存进一个栈结构中;然后可以对上下文进行其他修改;当想要回到之前保存的设置时,可以用restore()让栈结构向前返回一级,恢复之前的状态。连续调用save()可以把很多设置都保存到栈中,之后再连续调用restore()则可一级一级返回。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
ctx.fillStyle = "red";
ctx.save();

ctx.fillStyle = "green";
ctx.translate(100, 100);
ctx.save();

ctx.fillStyle = "blue";
ctx.fillRect(0, 0, 100, 200); // 从(100, 100)开始绘制蓝色矩形

ctx.restore();
ctx.fillRect(10, 10, 100, 200); // 从(110, 110)开始绘制绿色矩形

ctx.restore();
ctx.fillRect(0, 0, 100, 200); // 从(0, 0)开始绘制红色矩形

图像

1
2
3
4
5
6
7
8
9
10
11
const image = document.images[0];

// (10, 10)为起点将图像画到画布上
ctx.drawImage(image, 10, 10);

// (50, 10)为起点,图像大小为20*30
ctx.drawImage(image, 50, 10, 20, 30);

// 将原始图像中以(0, 10)为起点,大小为50*50的部分
// 画到画布上以(0, 100)为起点,大小为40*60的地方
ctx.drawImage(image, 0, 10, 50, 50, 0, 100, 40, 60);

除了可以传<img>,还可以传另一个<canvas>元素,这样就能把另一个画布的内容绘制到当前画布上。

阴影

2D上下文会根据下面几个属性的值,自动为形状或路径绘制阴影。只要在绘制前先为这几个属性设置好值,就能在之后绘制形状或路径的时候自动产生阴影。

  • shadowColor:阴影颜色,默认黑色
  • shadowOffsetX:形状或路径x轴方向的阴影偏移量,默认为0
  • shadowOffsetY:形状或路径y轴方向的阴影偏移量,默认为0
  • shadowBlur:模糊的像素数,默认为0,即不模糊
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const ctx = drawing.getContext("2d");

// 设置阴影
ctx.shadowColor = "rgba(0, 0, 0, 0.5)";
ctx.shadowOffsetX = 5;
ctx.shadowOffsetY = 5;
ctx.shadowBlur = 4;

// 画红色矩形
ctx.fillStyle = "red";
ctx.fillRect(10, 10, 50, 50);

// 画蓝色矩形
ctx.fillStyle = "blue";
ctx.fillRect(30, 30, 50, 50);

渐变

首先需要创建一个指定大小的渐变实例CanvasGradient,有两种渐变方式如下。

  • 线性渐变createLinearGradient()
    接收4个参数:起点x坐标、起点y坐标、终点x坐标、终点y坐标。

  • 径向渐变createRadialGradient()
    接收6个参数,对应两个圆的圆心和半径:起点圆圆心x坐标、起点圆圆心y坐标、起点圆半径、终点圆圆心x坐标、终点圆圆心y坐标、终点圆半径。

创建之后,需要通过addColorStop()来指定色标,它接收2个参数:作为色标的一个从0(开始的颜色)到1(结束的颜色)之间的数字、颜色值。

1
2
3
4
5
6
7
8
9
10
// 从(30, 30)到(70, 70),起点为白色,终点为黑色的线性渐变
const lGradient = ctx.createLinearGradient(30, 30, 70, 70);
lGradient.addColorStop(0, "white");
lGradient.addColorStop(1, "black");

// 径向渐变从以(55, 55)为圆心,10为半径的圆,
// 拓展到以(55, 55)为圆心,30为半径的圆处
const rGradient = ctx.createRadialGradient(55, 55, 10, 55, 55, 30);
rGradient.addColorStop(0, "white");
rGradient.addColorStop(1, "black");

创建好渐变之后,就可以把fillStylestrokeStyle设置为这个对象,从而使用渐变来绘制形状或描边。这里要注意的是渐变坐标和形状坐标要斟酌匹配一下,尽量不要出现渐变在图形中突然消失之类的。

1
2
3
4
5
6
7
// 绘制红色矩形
ctx.fillStyle = "red";
ctx.fillRect(10, 10, 50, 50);

// 绘制渐变矩形
ctx.fillStyle = gradient;
ctx.fillRect(30, 30, 50, 50);

模式

模式就是重复,可以用来填充或描边图形。首先要用createPattern()创建模式,此方法接收2个参数:一个<img>/<vedio>/<canvas>元素、表示如何重复的字符串(和CSS的background-repeat属性值相同,包括repeatrepeat-xrepeat-yno-repeat)。
这里要注意的是,模式与渐变一样,都是从画布的(0, 0)开始的。将fillStyle设置为模式,只表示在特定的区域内「显示」重复的图像,而不是从特定的区域起点开始「绘制」重复的图像。

1
2
3
4
5
6
const image = document.images[0];
const pattern = ctx.createPattern(image, "repeat");

// 绘制矩形
ctx.fillStyle = pattern;
ctx.fillRect(10, 10, 150, 150);

像素操作

可以通过getImageData()来取得原始图像数据,此方法接收4个参数:欲取数据的区域的x坐标、y坐标、宽度、高度。它会返回ImageData的实例,每个ImageData都有3个属性:widthheightdata,其中data是数组,按顺序保存着图像中每个像素的rgba值(0~255)。

1
2
3
4
const imageData = ctx.getImageData(10, 5, 50, 50);
const data = imageData.data;
const [red1, green1, blue1, alpha1,
red2, green2, blue2, alpha2] = data;

还可以通过putImageData()来把图像数据回写,绘制到画布上,此方法接收3个参数:一个ImageData实例、x坐标、y坐标。通常的使用方式是,取得data、对data进行一些操作(比如将某像素点上rgb都设置为rgb的平均值等)、再把data写回ImageData填回去。

合成

  • 透明度globalAlpha
    值为01之间的一个数字,表示后续操作的全局透明度,默认值为0(即不透明)。下面这个例子中,蓝色矩形位于红色矩形之上,如果没有在画蓝色矩形之前设置透明度,那么重叠的地方就会被蓝色覆盖,而现在蓝色矩形呈现半透明效果,因此可以透过它看到下面的红色矩形。
1
2
3
4
5
6
7
8
9
10
11
12
13
// 绘制红色矩形
ctx.fillStyle = "red";
ctx.fillRect(10, 10, 50, 50);

// 修改全局透明度
ctx.globalAlpha = 0.5;

// 绘制蓝色矩形
ctx.fillStyle = "rgba(0, 0, 255, 1)";
ctx.fillRect(30, 30, 50, 50);

// 修改全局透明度
ctx.globalAlpha = 0;
  • 图形结合方式globalCompositionOperation
    值为字符串,取值如下(后:后画的;先:先画的),表示后绘制的图形应该怎样与先绘制的图形结合。其实文档上好像还不止这么些,可以参考参考。下面的例子中,蓝色矩形位于红色矩形之下。
    • source-over:后在先上,为默认值
    • source-in:后重叠部分可见,其它(指后非重叠+前)都透明
    • source-out:后非重叠部分可见,其它(指后重叠+前)都透明
    • source-atop:后重叠部分可见,先非重叠部分可见
    • destination-over:后在先下
    • destination-in:先重叠部分可见,其它(指先非重叠+后)都透明
    • destination-out:先非重叠部分可见,其它(指先重叠+后)都透明
    • destination-atop:先重叠部分可见,后非重叠部分可见
    • lighter:重叠部分值相加,使该部分变亮
    • copy:当有重叠时,只显示后画的
    • xor:重叠部分异或操作(?)
1
2
3
4
5
6
7
8
9
10
// 绘制红色矩形
ctx.fillStyle = "red";
ctx.fillRect(10, 10, 50, 50);

// 设置合成操作
ctx.globalCompositionOperation = "destination-over";

// 绘制蓝色矩形
ctx.fillStyle = "rgba(0, 0, 255, 1)";
ctx.fillRect(30, 30, 50, 50);

3D上下文

WebGL是针对Canvas的3D上下文。与其它Web技术不同,WebGL不是W3C制定的标准,而是由Khronos Group制定的。这个group还设计了其它图形处理API,如WebGL基于的OpenGL ES 2.0。

书从这里就开始讲类型化数组,令人比较一头雾水,遂去看了Learn WebGL serialsWebGL Fundamentals,稍微理解了一点点点点…感觉要学计算机图形学+项目实践才能比较透彻,这里先鸽一下,把书里的先看完。

基础知识

ArrayBuffer

以JavaScript的精度,完全支持不了WebGL涉及的复杂计算需要,因此WebGL引入了类型化数组(typed array)。它其实也是数组,只不过元素被设置为了ArrayBuffer这个类型。ArrayBuffer对象以字节为单位,表示内存中的指定字节数。

1
2
3
4
5
// 这个buffer含20字节
const buffer = new ArrayBuffer(20);

// 可以通过byteLength属性获取字节数
const bytes = buffer.byteLength;

视图

可以使用ArrayBuffer来创建视图,常见的视图是DataView类型的,它的构造函数接收参数:一个ArrayBuffer、可选的字节偏移量(表示从该字节开始选择)、可选的字节数(表示选择这么多字节)。

1
2
3
4
5
6
7
8
9
10
11
12
// 用整个buffer创建一个视图
var view = new DataView(buffer);

// 创建一个开始于字节9的视图
var view = new DataView(buffer, 9);

// 创建一个开始于字节9,结束于字节18的视图
var view = new DataView(buffer, 9, 10);

// 可通过属性获取字节偏移量、字节长度
alert(view.byteOffset);
alert(view.byteLength);

读取和写入DataView时,要根据实际操作的数据类型,选择相应的gettersetter方法,下面是DataView支持的数据类型及响应的读写方法。其中offset是字节偏移量;littleEndian是一个布尔值,表示读写数值时是否采用小端字节序(即数据的最低有效位保存在低内存地址),默认为false的大端字节序(即数据的最低有效位保存在高内存地址)。

数据类型 getter setter
有符号8位整数 getInt8(offset) setInt8(offset, value)
无符号8位整数 getUint8(offset) setUint8(offset, value)
有符号16位整数 getInt16(offset, littleEndian) setInt16(offset, value, littleEndian)
无符号16位整数 getUint16(offset, littleEndian) setUint16(offset, value, littleEndian)
有符号32位整数 getInt32(offset, littleEndian) setInt32(offset, value, littleEndian)
无符号32位整数 getUint32(offset, littleEndian) setUint32(offset, value, littleEndian)
32位浮点数 getFloat32(offset, littleEndian) setFloat32(offset, value, littleEndian)
64位浮点数 getFloat64(offset, littleEndian) setFloat64(offset, value, littleEndian)

当使用这样的读写方法,我们需要明确地管理数据细节,举例如下。

1
2
3
4
5
6
7
const buffer = new ArrayBuffer(20);
const view = new DataView(buffer);

view.setUint16(0, 25);
view.setUint16(2, 50); // 不能从字节1开始,因为上面的uint16占了2个字节

const value = view.getUint16(0);

由于这里的offset使用字节偏移量而非数组元素数,因此可以通过不同的方式来访问同一字节,举例如下。在这个例子中,数值25以16位无符号整数形式写入,字节偏移量为0,即0000 0000 0001 1001;再以8位有符号整数形式读取数据,偏移量为0时,读取到的是16位中的前8位,即0000 0000

1
2
3
4
5
const buffer = new ArrayBuffer(20);
const view = new DataView(buffer);

view.setUint16(0, 25);
alert(view.getInt8(0)); // 0

以字节级别读写需要我们明确记住每个数据的具体保存位置,这会带来很多工作量,因此类型化视图应运而生。

类型化视图

一般也被称为类型化数组,分为以下几种Int8ArrayUint8ArrayInt16ArrayUint16ArrayInt32ArrayUint32ArrayFloat32ArrayFloat64Array,它们都继承了DataView

它们有三种构造方式,第一种和DataView一样,先创造buffer,再传入相应参数表示占有buffer的特定位置;第二种是传入希望数组保存的元素数,构造函数就会自动创建一个包含足够字节数的buffer;第三种则是把常规数组转换为类型化视图,只需传入常规数组即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/*--- 构造方式1 ---*/

// 创建一个新数组,使用整个buffer
const int8s = new Int8Array(buffer);
// 只使用从字节9开始的buffer
const int16s = new Int16Array(buffer, 9);
// 只使用从字节9到字节18的buffer
const uint16s = new Uint16Array(buffer, 9, 10);

/*--- 构造方式2 ---*/

// 创建一个数组保存10个8位整数(10字节)
const int8s = new Int8Array(10);
// 创建一个数组保存10个16位整数(20字节)
const int16s = new Int16Array(10);

/*--- 构造方式3 ---*/

// 创建一个数组保存5个8位整数(5字节)
const int8s = new Int8Array([10, 20, 30, 40, 50]);

既然可以指定使用哪部分字节段,那很显然,同一个buffer中可以保存不同类型的数值,举例如下。

1
2
3
// 使用buffer的一部分保存8位整数,另一部分保存16位整数
const int8s = new Int8Array(buffer, 0, 0);
const uint6s = new Uint16Array(buffer, 10, 10);

在这里,不同的类型,占用不同的字节数,那举个例子,20B的ArrayBuffer可以保存20个Int8Array/Uint8Array,或者10个Int16Array/Uint16Array,或者5个Int32Array/Uint32Array/Float32Array,或者2个Float64Array

虽然占的字节数非常显而易见,但是每个视图构造函数都还是有提供一个BYTES_PER_ELEMENT属性,表示类型化数组中每个元素需要多少字节。可以利用它来辅助初始化,就像sizeof那样。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
alert(Uint8Array.BYTES_PER_ELEMENT); // 1
alert(Float32Array.BYTES_PER_ELEMENT); // 4

// 需要10个元素空间,耗费空间为10*1字节,从buffer起始处开始
const int8s = new Int8Array(
buffer,
0,
10 * Int8Array.BYTES_PER_ELEMENT);

// 需要5个元素空间,耗费空间为5*2字节,从buffer中int8s结束处开始
const uint16s = new Uint16Array(
buffer,
int8s.byteOffset + int8s.byteLength,
5 * Uint16Array.BYTES_PER_ELEMENT);

创建说完了,那就要说说访问了。类型化数组的访问使用方括号的下标语法,就和普通数组一样。只不过这里要注意数值是否够放的问题,如果相应元素指定的字节数放不下相应的值,那么实际保存的值则为最大可能值的模,譬如无符号16位整数所能表达的最大数值为65535,如果想存65536,那实际存的是0;如果想存65537,那实际存的是1

1
2
3
const uint16s = new Uint16Array(10);
uint16s[0] = 65537;
alert(uint16s[0]); // 1

除了创建、访问,类型化数组还有一个subarray()方法,用它可以基于底层buffer的子集创建一个新的视图。它接收2个参数:开始元素的索引、结束元素的索引。

1
2
const uint16s = new Uint16Array(10);
const sub = uint16s.subarray(2, 5);

这里的sub也是Uint16Array的一个实例,且底层与uint6s都基于同一个ArrayBuffer,通过大视图创建小视图的好处是可以避免修改到无关元素。

获取上下文

canvas.getContext("webgl"),不过相比2D上下文,这里可以给getContext()传递第二个参数,作为一些配置项。该参数是一个对象,包含下面这些属性:

  • alpha:默认true,表示为上下文创建Alpha通道缓冲区
  • depth:默认true,表示可使用16位深缓冲区
  • stencil:默认false,表示可使用8位模板缓冲区
  • antialias:默认true,表示将使用默认机制执行抗锯齿操作
  • premultipliedAlpha:默认true,表示绘图缓冲区有预乘Alpha值
  • preserveDrawingBuffer:默认false,表示在绘图完成后保留绘图缓冲区
1
2
3
4
5
6
7
8
9
10
11
const drawing = document.getElementById("drawing");
if(drawing.getContext) {
// 避免getContext()无法获取WebGL上下文而抛出的错误
try {
const gl = drawing.getContext("webgl");
} catch(e) {}

if(!gl) {
alert("WebGL context could not be created");
} else {}
}

API特点

基本上都是OpenGL的命名特点,毕竟基于它嘛。

对于常量,在OpenGL中一般带前缀GL_,而在WebGL中则去掉了相应的前缀,它以这种方式支持大多数OpenGL常量。比如OpenGL的GL_COLOR_BUFFER_BIT常量在WebGL中是gl.COLOR_BUFFER_BIT

对于方法,很多方法名都会传达有关数据类型的信息。比如gl.uniform4f()意味着要接收4个浮点数,gl.uniform3i()意味着要接收3个整数,gl.uniform3iv()意味着要接收一个包含3个值的整数数组(vvector)。

错误

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上下文

如果发生多个错误,需要反复调用它直到返回gl.NO_ERROR,这时可以使用循环来调用:

1
2
3
4
5
let errorCode = gl.getError();
while(errorCode) {
console.log(`Error occured: ${errorCode}`);
errorCode = gl.getError();
}

准备绘图

在操作WebGL上下文之前,需要使用某种颜色清除<canvas>,可通过方法clearColor(r, g, b, a)来进行。

1
2
gl.clearColor(0, 0, 0, 1); // 黑色
gl.clear(gl.COLOR_BUFFER_BIT);

上例中,先把清理颜色缓冲区设置为黑色,再调用clear()(与OpenGL的glClear()等价),传入的参数gl.COLOR_BUFFER_BIT告诉WebGL使用之前定义的颜色来填充相应区域。

视口与坐标

默认情况下,视口可以使用整个<canvas>区域,也可以通过viewport()来改变视口大小,只使用部分<canvas>,它接收4个参数:视口相对于<canvas>x坐标、y坐标、宽度、高度。

定义视口坐标与常规网页坐标不一样:视口坐标原点(0, 0)<canvas>左下角,x轴向右,y轴向上。

视口内部的坐标系与定义视口的坐标系也不一样:在视口内部,坐标原点(0, 0)是视口的中心点,因此视口左下角为(-1, -1),右上角为(1, 1)

1
2
3
4
5
6
7
8
// 用整个画布
gl.viewport(0, 0, drawing.width, drawing.height);
// 用画布左下角1/4区域
gl.viewport(0, 0, drawing.width / 2, drawing.height / 2);
// 用画布左上角1/4区域
gl.viewport(0, drawing.height / 2, drawing.width / 2, drawing.height / 2);
// 用画布右下角1/4区域
gl.viewport(drawing.width / 2, 0, drawing.width / 2, drawing.height / 2);

缓冲区buffer

WebGL中呈现的3D图形里,比较重点的决定因素是Vertex(开始还打成了Vortex…奇异人生里那个俱乐部的名字),即各个平面之间的交点,它们的坐标信息会存储在类型化数组中,而我WebGL如果想用这些数据,那就要将其转换过来,放到我WebGL的缓冲区里。那么我们的操作就是,先创建缓冲区,再将该缓冲区指定给WebGL使用,再把原始数据填进该缓冲区。

1
2
3
4
5
6
// 创建buffer
const buffer = gl.createBuffer();
// 将buffer绑定到WebGL上下文
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
// 向buffer填充数据
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([0, 0.5, 1]), gl.STATIC_DRAW);

这里,第二步将buffer绑定到WebGL上下文之后,所有的缓冲区操作都会在buffer对象中执行,因此第三步的填充数据无需明确传入buffer对象。这里的第三步中,第一个参数表示绑定的连接点,如果想用drawElements()输出缓冲区内容,也可以传入gl.ELEMENT_ARRAY_BUFFER;第二个参数处使用Float32Array初始化了buffer,通常都会用这个数据类型来保存顶点信息;第三个参数处则表示使用缓冲区的方式,包括下面几种取值:

  • gl.STATIC_DRAW:数据只加载一次,在多次绘图中使用
  • gl.STREAM_DRAW:数据只加载一次,在几次绘图中使用
  • gl.DYNAMIC_DRAW:数据动态改变,在多次绘图中使用

在包含缓冲区的页面重载之前,缓冲区始终保留在内存中。如果不想要某个缓冲区了,可以使用gl.deleteBuffer(buffer)来释放内存。

着色器shader

搞定了各个顶点的坐标信息,那就要知道这些顶点要以什么样的方式渲染到平面页面上,那就引入了shader。shader是OpenGL中的概念,WebGL里有两种:vertex shader和fragment shader,前者用于将3D顶点转换为需要渲染的2D点,后者用于准确计算要绘制的每个像素的颜色。WebGL的shader并不是用JavaScript写的,而是用GLSL写的(OpenGL Shading Language)。

每个shader都有一个main()方法,它会在绘图期间重复执行。为shader传递数据的方式有两种:通过attribute可以向vertex shader传入顶点信息,通过uniform可以向任何shader传入常量值。下面是一个简单的vertex shader例子:

1
2
3
4
attribute  vec2 aVertexPosition;
void main() {
gl_Position = vec4(aVertexPosition, 0.0, 1.0);
}

在这个shader中,我们定义了一个叫aVertexPosition的attibute,它是包含2个元素的数组(vec2),表示x坐标和y坐标。即使只接收到了2个坐标,vertex shader也必须把一个包含4个方面信息的顶点赋值给特殊变量gl_Position,这里我们就创建了一个包含4个元素的数组(vec4),填补了缺失的坐标,把2D坐标转换成了3D坐标。

fragment shader与上面类似,除了只能通过uniform传入数据,举例如下:

1
2
3
4
uniform vec4 uColor;
void main() {
gl_FragColor = uColor;
}

在这个shader中,一个包含4方面信息(vec4)的统一颜色uColor被赋给变量gl_FragColor,表示绘图时使用的颜色。值得注意的是,uColor的值在这个shader内部不能改变。

程序program

浏览器不能理解GLSL,因此需要经过一系列处理。一般会按照这么个顺序处理:

  • 把GLSL程序放到<script>里并指定一个自定义的type(这样由于无法识别type,浏览器不会解析其中的内容)
  • 通过text属性提取文本,即GLSL的代码内容
  • 通过gl.createShader()创建着色器对象(传入代表着色器类型的gl.VERTEX_SHADERgl.FRAGMENT_SHADER)
  • 通过gl.shaderSource()将GLSL代码和着色器对象关联
  • 通过gl.compileShader()来编译着色器
1
2
3
4
5
6
7
8
9
10
11
12
13
<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>
1
2
3
4
5
6
7
8
9
const vertexGLSL = document.getElementById("vertexShader").text;
const vertexShader = gl.createShader(gl.VERTEX_SHADER);
gl.shaderSource(vertexShader, vertexGLSL);
gl.compileShader(vertexShader);

const fragmentGLSL = document.getElementById("fragmentShader").text;
const fragmentShader = gl.createShader(gl.FRAGMENT_SHADER);
gl.shaderSource(fragmentShader, fragmentGLSL);
gl.compileShader(fragmentShader);

「着色器」都编写好了之后,就要考虑「着色器程序」了。这里的做法是:

  • 通过gl.createProgram()创建着色器程序
  • 通过gl.attachShader()gl.linkProgram()将两个着色器对象与程序关联(将着色器封装到程序中)
  • 通过gl.useProgram()将着色器程序与WebGL关联(通知WebGL可以使用这个程序了)
  • 调用gl.useProgram()后,所有后续的绘图操作都将使用这个程序
1
2
3
4
const program = gl.createProgram();
gl.attachShader(program, vertexShader);
gl.attachShader(program, fragmentShader);
gl.linkProgram(program);

为shader传入值

之前定义的shader都必须接收一个值才能工作,为此需要:

  • 先找到接收这个值的变量位置
  • 再基于变量的位置来赋值

对于uniform变量,赋值过程如下:

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

这里,通过gl.getUniformLocation()可以返回一个对象,表示uniform变量uColor在内存中的位置,再通过gl.uniform4fv()uColor赋值。

对于attribute变量,也是差不多的赋值过程。

1
2
3
const aVertexPosition = gl.getAttribLocation(program, "aVertexPosition");
gl.enableVertexAttribArray(aVertexPosition);
al.vertexAttribPointer(aVertexPosition, itemSize, gl.FLOAT, false, 0, 0);

这里,通过gl.getAttribLocation()可以找到attribute变量aVertexPosition在内存中的位置;接着通过gl.enableVertexAttribArray()来启用它;最后创建了指针,指向gl.bindBuffer()指定的缓冲区,并将其保存在aVertexPosition中,以便vertex shader使用。其中vertexAttribPointer()参数分别为:attribute位置、一个顶点的元素数(比如二维坐标为2,三维坐标为3)、顶点坐标类型、坐标是否标准化、表示取得下一值要跳过多少数组元素的步长值、起点偏移量。

调试shader和program

和WebGL中其它操作一样,着色器操作也可能失败,而且也是静默失败。如果想知道shader和program执行中是否发生了错误,需要手动查找。

1
2
3
4
5
6
7
if(!gl.getShaderParameter(vertexShader, gl.COMPILE_STATUS)) {
alert(gl.getShaderInfoLog(vertexShader));
}

if(!gl.getProgramParameter(program, gl.LINK_STATUS)) {
alert(gl.getProgramInfoLog(program));
}

这个例子中,shader部分通过gl.getShaderParameter()获取了shader的编译状态,当编译成功时,返回true,否则为false,当编译失败时,通过gl.getShaderInfoLog()可以获取错误信息;program部分类似,先获取了program的链接状态(是最常出现错误的地方),再打印日志。

绘图

WebGL只能绘制三种形状:点、线、三角。其它所有形状都是由这三种基本形状合成之后,再绘制到三维空间的。绘制操作由两种:用于数组缓冲区的gl.drawArrays()、用于元素数组缓冲区的gl.drawElements()。它们的第一个参数都是一个常量,表示绘制的形状,如下:

  • gl.POINTS
    将每个顶点当成点来绘制。
  • gl.LINES
    将数组当成一系列顶点,并在顶点间画线。每个顶点既是起点又是终点,因此数组长度要为偶数。
  • gl.LINE_LOOP
    将数组当成一系列顶点,并在顶点间画线。线条依次穿过第一个顶点、第二个顶点…最后一个顶点、再回到第一个顶点,形成一个轮廓。
  • gl.LINE_STRIP
    gl.LINE_LOOP类似,除了不把最后一个和第一个连起来。
  • gl.TRIANGLES
    将数组当成一系列顶点,并在顶点间画三角形。除非明确指定,每个三角形都单独绘制,不与其它三角形共享顶点。
  • gl.TRIANGLES_STRIP
    gl.TRIANGLES类似,除了要复用顶点(将前三个顶点后的那个顶点,与它的前两个顶点结合,构成新的三角形),如数组中包含A、B、C、D,则第一个三角形连接ABC,第二个三角形连接BCD。
  • gl.TRIANGLES_FAN
    gl.TRIANGLES类似,除了要复用顶点(将前三个顶点后的那个顶点,与它的前一个顶点和第一个顶点结合,构成新的三角形),如数组中包含A、B、C、D,则第一个三角形连接ABC,第二个三角形连接ACD。

下面举一个用gl.drawArrays()画三角形的例子。

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
// 假设已经使用前面定义的着色器清除了视口

// 定义三个顶点及每个顶点的x和y坐标
let vertices = new Float32Array([0, 1, 1, -1, -1, -1]),
buffer = gl.createBuffer(),
vertexSetSize = 2,
vertexSetCount = vertices.length / vertexSetSize,
uColor,
aVertexPosition;

/*----- 参考`缓冲区buffer` -----*/

// 把数据放到缓冲区
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW);

/*----- 参考`为shader传入值` -----*/

// 为fragment shader传入颜色值
uColor = gl.getUniformLocation(program, "uColor");
gl.uniform4fv(uColor, [0, 0, 0, 1]);

// 为shader传入顶点信息
aVertexPosition = gl.getAttribLocation(program, "aVertexPosition");
gl.enableVertexAttribArray(aVertexPosition);
gl.vertexAttribPointer(aVertexPosition, vertexSetSize, gl.FLOAT, false, 0, 0);

/*----- 参考`绘图` -----*/
gl.drawArrays(gl.TRIANGLES, 0, vertexSetCount);

上面绘图的结果就是在(0, 1)(1, -1)(-1, -1)三点之间绘制了填充了黑色的三角形。

当修改gl.drawArrays()的第一个参数时,绘制三角形的方式会不同,如下:

纹理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const image = new Image();
let texture;
image.src = "smile.gif";
image.onload = function () {
// 创建新纹理
texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, texture);
// 设置像素存储格式,此常量为WebGL独有的常量,主要是因为GIF、JPEG、PNG图像与WebGL使用的坐标系不一样,如果未设置则会在解析图像时发生混乱
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);
// 将图像绑定到纹理
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.UNSIGNED_BYTE, image);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
// 清除当前纹理
gl.bindTexture(gl.TEXTURE_2D, null);
}

要注意的是,用作纹理的图像必须与包含页面来自同一个域,或者存在启用了CORS的服务器上。

像素操作

和2D上下文类似,通过WebGL上下文也能读取像素值,使用方法为readPixels(),参数为:

  • x
  • y
  • 宽度
  • 高度
  • 图像格式(几乎总是gl.RGBA)
  • 数据类型(用来指定保存在类型化数组中的数据的格式)
  • 类型化数组(从帧缓冲区读取的像素信息保存处)。

其中前四个参数表示读取哪个区域内的像素。要注意的是,数据类型和类型化数组的关联有以下限制:

  • 如果数据类型是gl.UNSIGNED_BYTE,那么类型化数组必须是Uint8Array
  • 如果数据类型是gl.UNSIGNED_SHORT_5_6_5gl.UNSIGNED_SHORT_4_4_4_4gl.UNSIGNED_SHORT_5_5_5_1,那么类型化数组必须是Uint16Array
1
2
const pixels = new Uint8Array(25 * 25);
gl.readPixels(0, 0, 25, 25, gl.RGBA, gl.UNSIGNED_BYTE, pixels);

这个例子中,从帧缓冲区读取了25*25像素的区域,将读取到的像素信息保存在pixels数组中,其中每个像素的颜色都由4个数组元素表示,分别为R、G、B、A,都介于0到255之间。

还要注意的是,在WebGL图像绘制发生前调用此方法,返回的像素数据没问题;而当绘制发生后,帧缓冲区会恢复原始的干净状态,调用此方法返回的像素数据就是清除缓冲区之后的状态。如果想要在绘制后读取像素数据,需要在初始化WebGL上下文时将preserveDrawingBuffer设置为true,这样可以让帧缓冲区在下一次绘制之前保留其最后的状态,不过会造成性能损失。