网易乐得技术团队

webGL环境贴图

自从接触webGL以来,看过很多令人惊叹的例子,但一直没有尝试去实现过。这次花了一周时间,尝试实现了一个环境贴图的例子,过程比自己想象的要艰辛,因为资料太少了,每天取得的一点进步感觉都是一种幸运。虽然最终实现的还不够完善,但也还看得过去,最主要的是搞懂了实现的原理以及一些需要注意的细节,以后可以实现更细致的效果,比如金属、玻璃等效果。写这篇文章的目的,就是通过例子,将环境贴图的实现过程进行详细记录,以备查阅。

例子地址:http://pimg1.126.net/nfop/mail-maker/reflect/index.html

截图:

0

具体实现过程如下:

首先是准备工作,为了方便起见,所有的代码都写在一个html文件中,并加载一个处理矩阵的库gl-matrix.js。

1
2
3
4
5
6
7
8
9
10
11
12
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>环境贴图</title>
</head>
<body>
<canvas id="myCanvas" width="500" height="500"></canvas>
<script src="//cdn.bootcss.com/gl-matrix/2.3.2/gl-matrix-min.js"></script>
...
</body>
</html>

然后准备着色器,需要准备两个着色器,一个是渲染天空盒的着色器,一个是渲染立方体的着色器。我们把着色器代码以模板的方式存放在script标签里边。

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
61
<!-- 天空盒着色器 -->
attribute vec3 a_position;
attribute vec3 a_color;
uniform mat4 u_matrix;
varying vec3 v_color;
varying vec3 v_texCoord; // 天空盒纹理坐标
void main(){
gl_Position = u_matrix * vec4(a_position, 1.0);
v_color = a_color;
// 因为天空盒的中心在原点,所以纹理坐标就是顶点坐标
v_texCoord = a_position;
}
precision mediump float;
uniform samplerCube u_sampler; // 采样器
varying vec3 v_color;
varying vec3 v_texCoord;
void main(){
vec4 baseColor = vec4(v_color, 1.0); // 本来的颜色
vec4 texture = textureCube(u_sampler, v_texCoord); // 纹理颜色
gl_FragColor = baseColor * texture;
}
<!-- 立方体着色器 -->
attribute vec4 a_position;
attribute vec4 a_normal; // 顶点法线
uniform mat4 u_matrix; // 模型视图投影矩阵
uniform mat4 u_mMatrix; // 模型矩阵
uniform mat4 u_normalMatrix; // 模型矩阵的逆转置矩阵
varying vec3 v_normal;
varying vec3 v_position;
void main(){
gl_Position = u_matrix * a_position;
// 因顶点位置变换,将正确的顶点位置传给片元着色器
v_position = vec3( u_mMatrix * a_position );
// 因顶点位置变换,将正确的法向量传给片元着色器
v_normal = normalize( vec3( u_normalMatrix * a_normal ) );
}
precision mediump float;
uniform samplerCube u_sampler; // 采样器
uniform vec3 u_eyePosition; // 相机的位置向量
varying vec3 v_normal;
varying vec3 v_position;
void main(){
// 计算视线向量eye
vec3 eye = normalize( u_eyePosition - v_position );
// 计算发射向量,即纹理坐标
vec3 texCoord = reflect( -eye, v_normal );
gl_FragColor = textureCube( u_sampler, texCoord );
}

其他代码都很常规,只有立方体片元着色器中计算纹理坐标的计算方法需要解释下,这也是环境贴图效果的关键技术,其实就是物理学的镜面反射原理:

1

如上图,首先需要计算视线向量,然后以物体表面法线为轴,计算反射向量,着色器语言(GLSL ES)内置了reflect函数来计算反射向量,注意这里计算的视线向量eye的方向是反的,所以在传到reflect函数中的时候是-eye:

1
2
3
4
5
6
7
// 计算视线向量eye
vec3 eye = normalize( u_eyePosition - v_position );
// 计算发射向量,即纹理坐标
vec3 texCoord = reflect( -eye, v_normal );
gl_FragColor = textureCube( u_sampler, texCoord );

然后就是js代码,下面是主体结构:

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
61
62
63
64
var canvas = document.getElementById('myCanvas'); // canvas
var gl = canvas.getContext('webgl'); // 上下文
// 天空盒着色器程序对象
var program = createShader(gl, document.getElementById('vShaderSource').innerHTML,
document.getElementById('fShaderSource').innerHTML);
// 着色器变量地址存到程序对象的属性中
program.a_position = gl.getAttribLocation(program, 'a_position');
program.a_color = gl.getAttribLocation(program, 'a_color');
program.u_matrix = gl.getUniformLocation(program, 'u_matrix');
// 立方体着色器程序对象
var program2 = createShader(gl, document.getElementById('vShaderSource2').innerHTML,
document.getElementById('fShaderSource2').innerHTML);
// 着色器变量地址存到程序对象的属性中
program2.a_position = gl.getAttribLocation(program2, 'a_position');
program2.a_normal = gl.getAttribLocation(program2, 'a_normal');
program2.u_matrix = gl.getUniformLocation(program2, 'u_matrix');
program2.u_mMatrix = gl.getUniformLocation(program2, 'u_mMatrix');
program2.u_normalMatrix = gl.getUniformLocation(program2, 'u_normalMatrix');
program2.u_eyePosition = gl.getUniformLocation(program2, 'u_eyePosition');
program2.u_sampler = gl.getUniformLocation(program2, 'u_sampler');
// 离屏绘制尺寸
var OFF_SCREEN_WIDTH = 512;
var OFF_SCREEN_HEIGHT = 512;
// 立方贴图六个面的目标
var cubeTextureTargets = [
gl.TEXTURE_CUBE_MAP_POSITIVE_X,
gl.TEXTURE_CUBE_MAP_NEGATIVE_X,
gl.TEXTURE_CUBE_MAP_POSITIVE_Y,
gl.TEXTURE_CUBE_MAP_NEGATIVE_Y,
gl.TEXTURE_CUBE_MAP_POSITIVE_Z,
gl.TEXTURE_CUBE_MAP_NEGATIVE_Z
];
// 初始化天空盒buffer
var sky = initCubeBuffer();
// 初始化立方体buffer
var cube = initCubeBuffer();
// 初始化帧缓冲区
var fbo = initFBO();
// 初始化天空盒纹理
var skyTexture = initSkyTexture();
// 相机的位置
var eyePosition = new Float32Array([2.0, 2.0, 6.0]);
// 视图投影矩阵
var pvMatrix = createPvMatrix({eye: eyePosition});
// 渲染循环
var t = 0;
var loop = function(){
drawFBO(t); // 在帧缓冲区中绘制天空盒
draw(t); // 常规绘制天空盒和立方体
t++;
window.requestAnimationFrame(loop);
}
loop();
  1. 首先获取webGL上下文。
  2. 然后构建着色器程序对象,然后在程序对象中设置一些属性来存放着色器变量的地址。createShader函数是构建着色器程序对象的,该函数从《webGL编程指南》中拷贝,并做了少许修改。
  3. 然后设置了离屏绘制的尺寸,所谓离屏绘制,指的是在帧缓冲区中进行绘制,因为在帧缓冲区中绘制的图像是不会立即显示到屏幕的,所以又叫离屏绘制。
  4. 然后是将立方贴图的六个目标放到一个数组中,这六个目标代表了立方体纹理(textureCube)的六个方向,按顺序分别是右、左、上、下、前、后。
  5. 然后是初始化一些buffer,这里天空盒和立方体用了一样的buffer,因为天空盒也是立方体。
  6. 然后初始化帧缓冲区,帧缓冲区的理解可以参考《webGL编程指南》第10章,书上做了详细介绍。
  7. 然后初始化天空盒纹理,在网上找了一套天空盒纹理图片,但只有五张图片,底部的贴图没有,所以随便下载了一张图片替换,这里需要注意的是,六张纹理贴图必须是尺寸一样的,尺寸大小是2的N次方,而且宽高必须一样。
  8. 然后设置视图投影矩阵,其实就是设置一个透视相机,并修改了相机的默认位置,这里就是在{x:2.0, y:2.0, z:6.0}的位置来观察3D世界。createPvMatrix也是为了方便自己写的函数,具体代码如下:
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
function createPvMatrix(params){
var params = params || {};
var eye = params.eye || new Float32Array([0.0, 0.0, 10.0]);
var center = params.center || new Float32Array([0.0, 0.0, 0.0]);
var up = params.up || new Float32Array([0.0, 1.0, 0.0]);
// 视图矩阵
var vMatrix = mat4.create();
mat4.lookAt(
vMatrix, // 输出的值会赋给这个变量
eye, // 眼睛的位置
center, // 眼睛聚焦的位置
up // 上方向
);
// 投影矩阵
var pMatrix = mat4.create();
mat4.perspective(
pMatrix, // 输出的值会赋给这个变量
70, // 眼睛视野的垂直角度
canvas.width/canvas.height, // 视口宽高比
0.1, // 眼睛离视椎体近截面距离
1000.0 // 眼睛离视椎体远截面距离
);
// 视图投影矩阵 = 投影矩阵 * 视图矩阵,相当于three.js封装的相机
var pvMatrix = mat4.create();
mat4.multiply(pvMatrix, pMatrix, vMatrix);
return pvMatrix;
}

最后就进入渲染环节,这里使用requestAnimationFrame进行循环渲染,这样就能产生动画。
首先在帧缓冲区中动态绘制天空盒,然后正常绘制天空盒和立方体。

其他都没什么可说的,这里需要详细解释的是在帧缓冲区中动态绘制天空盒的代码:

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
61
function drawFBO(t){
gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
gl.viewport(0, 0, OFF_SCREEN_WIDTH, OFF_SCREEN_HEIGHT);
gl.clearColor(0.0, 0.0, 0.0, 1.0);
gl.disable(gl.DEPTH_TEST);
gl.useProgram(program);
cubeTextureTargets.forEach(function(target, index){
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, target, fbo.texture, null);
var eye = new Float32Array([0.0, 0.0, 0.0]);
var up = vec3.create();
var center = vec3.create();
switch(index){
case 0:
// 看右
up[1] = -1.0;
center[0] = 1.0;
break;
case 1:
// 看左
up[1] = -1.0;
center[0] = -1.0;
break;
case 2:
// 看上
up[2] = -1.0;
center[1] = 1.0;
break;
case 3:
// 看下
up[2] = -1.0;
center[1] = -1.0;
break;
case 4:
// 看前
up[1] = -1.0;
center[2] = 1.0;
break;
case 5:
// 看后
up[1] = -1.0;
center[2] = -1.0;
break;
}
// FBO用自己的视图投影矩阵
var pvMatrixFBO = createPvMatrix({
eye: eye,
center: center,
up: up
});
drawSky(t, pvMatrixFBO);
});
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
}

首先需要绑定帧缓冲区,表示下面的绘制都是离屏绘制,最后绑定帧缓冲区为null,表示结束离屏绘制,进入常规绘制。
因为在帧缓冲区中绘制的目的是动态生成六幅贴图,不涉及到深度检测的问题,所以将深度检测关闭,理论上可以提高点性能。
然后将相机的位置置于原点{x:0, y:0, z:0},将相机焦点分别对准右、左、上、下、前、后,分别绘制天空盒,这样就动态生成了六张纹理贴图,这六张贴图就是用作后面绘制的立方体的纹理,立方体会根据镜面反射算法,提取纹素颜色,这样就产生了镜面效果。