在初级进阶课程,我们学习了纹理的绘制方法,不过那节我们只学习了如何使用2D纹理
进行贴图。事实上,WebGL 还支持将多个 2D 纹理组合成一个单一纹理,然后采用该单一纹理进行贴图,这种纹理称为立方体纹理CubeMap
,本节我们学习如何使用立方体纹理进行贴图。
采样原理
在讲解贴图方法之前,我们简单了解一下立方体纹理的采样原理。立方体纹理实质上由 6 个 2D 纹理组成,这 6 个2D 纹理对应立方体的每个面,立方体纹理可以理解为 6 个面都是图像的立方体。
那如何对立方体纹理进行采样呢?首先,立方体纹理对应的 6 个图像资源宽度和高度要相等。其次,使用一束从立方体中心位置发出的方向向量进行采样,方向向量就是我们传递给 GPU 的纹理坐标,该向量和立方体交点处的图像像素就是我们需要的采样值。
当模型是单位为 1 的立方体时,模型的顶点坐标即立方体纹理坐标。
贴图过程
立方体纹理的贴图过程和之前的 2D 纹理贴图有所区别,接下来我们演示一下。
着色器
因为是纹理操作,所以着色器部分需要增加纹理坐标和纹理资源。
顶点着色器
顶点着色器需要增加纹理坐标,立方体纹理的一个优点就是纹理坐标采用原始坐标值就可以。
precision mediump float;
// 接收顶点坐标 (x, y, z)
attribute vec3 a_Position;
uniform mat4 u_Matrix;
varying vec3 textCoord;
void main(){
gl_Position = u_Matrix * vec4(a_Position,1);
// 将顶点原始坐标赋值给纹理坐标。
textCoord = a_Position;
}
片元着色器
片元着色器部分也比较简单,将立方体纹理上纹理坐标对应的颜色信息赋值给 gl_FragColor 作为像素颜色。
precision mediump float;
//接收纹理坐标坐标 (x, y, z)
varying vec3 textCoord;
//samplerCube变量,用来接收立方体纹理。
uniform samplerCube u_Skybox;
void main()
{
gl_FragColor = textureCube(u_Skybox, textCoord);
}
加载资源
和 2D 纹理一样,在使用纹理之前,我们还是要先把资源加载到内存中,由于立方体纹理需要 6 个图像,所以我们需要定义一个图像资源加载器,它包含一个资源列表和加载完成后的回调事件。
function loadImages(imgList, callback){
let i = 0;
function ready (){
if(i >= resourceList.length){
callback();
return;
}
i++;
}
for(let i =0;i<resourceList.length;i++){
let img = new Image();
img.onload = ready;
img.src= resourceList[i];
}
}
有了加载器,我们将需要的六张图片加载到内存中。
loadImages(['right.png','left.png','top.png','down.png','back.png','front.png'], renderSkyBox);
renderSkyBox是图片加载完成后执行的回调,也就是下面的绘制过程。
纹理操作
图片资源加载到内存之后,我们就可以对纹理进行操作了。
1、创建纹理对象,并激活 0 号纹理单元。
//1、创建纹理对象
let texture = gl.createTexture();
//2、激活0号纹理单元
gl.activeTexture(gl.TEXTURE0);
//3、将 0 号纹理单元传入 GPU。
gl.uniform1i(u_Skybox, 0);
//4、当创建的纹理对象texture绑定到立方体纹理上
gl.bindTexture(gl.TEXTURE_CUBE_MAP, texture);
2、将 6 个面对应的纹理图像传到 GPU
gl.texImage2D(gl.TEXTURE_CUBE_MAP_POSITIVE_X, 0, gl.RGB, gl.RGB, gl.UNSIGNED_BYTE, document.getElementById('sky0'));
gl.texImage2D(gl.TEXTURE_CUBE_MAP_NEGATIVE_X, 0, gl.RGB, gl.RGB, gl.UNSIGNED_BYTE, document.getElementById('sky1'));
gl.texImage2D(gl.TEXTURE_CUBE_MAP_POSITIVE_Y, 0, gl.RGB, gl.RGB, gl.UNSIGNED_BYTE, document.getElementById('sky2'));
gl.texImage2D(gl.TEXTURE_CUBE_MAP_NEGATIVE_Y, 0, gl.RGB, gl.RGB, gl.UNSIGNED_BYTE, document.getElementById('sky3'));
gl.texImage2D(gl.TEXTURE_CUBE_MAP_POSITIVE_Z, 0, gl.RGB, gl.RGB, gl.UNSIGNED_BYTE, document.getElementById('sky4'));
gl.texImage2D(gl.TEXTURE_CUBE_MAP_NEGATIVE_Z, 0, gl.RGB, gl.RGB, gl.UNSIGNED_BYTE, document.getElementById('sky5'));
这里要注意一点,sky4是背面图像,sky 5 是正面图像,我们需要将背面图像传递给正面纹理
TEXTURE_CUBE_MAP_POSITIVE_Z
,将正面图像传递给背面纹理TEXTURE_CUBE_MAP_NEGATIVE_Z
。
3、设置图片放大缩小时的过滤参数,此处我们设置为就近取值。
gl.texParameteri(gl.TEXTURE_CUBE_MAP, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_CUBE_MAP, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
4、设置当纹理坐标没有落在任何一个面上,而是落在两个面之间时的纹理采样,使用gl.CLAMP_TO_EDGE 表示采用边缘的纹理像素。
gl.texParameteri(gl.TEXTURE_CUBE_MAP, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_CUBE_MAP, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
至此,纹理部分的操作就完成了,接下来我们要创建一个立方体代表天空盒,将图像贴在立方体的各个面上。
创建天空盒
我们先从立方体外部看一下立方体纹理的渲染效果,然后将摄像机放到立方体内部,讲解天空盒的实现方式。
在外部观察天空盒
1、创建一个边长为 500 的立方体,并将立方体转化成无索引形式。
边长长度没有限制,只要保证摄像机位置在立方体内部即可。
//索引立方体
let cube = createCube(500, 500, 500);
//无索引立方体
cube = transformIndicesToUnIndices(cube);
// 创建顶点缓冲对象。
let bufferInfo = createBufferInfoFromObject(gl, cube);
// 创建渲染对象
let object = new Model('sky');
object.setBufferInfo(bufferInfo);
objectList.add(object);
renderList.add({
programInfo: program,
model: object,
primitive: 'TRIANGLES',
renderType: 'drawArrays'
});
2、创建摄像机
let cameraPosition = new Vector3(0, 0, 600);
let target = new Vector3(0, 0, -1);
let up = new Vector3(0, 1, 0);
let cameraMatrix = matrix.lookAt(cameraPosition, target, up);
// 从相机矩阵取逆获取视图矩阵。
let viewMatrix = matrix.inverse(cameraMatrix);
3、创建视图投影矩阵,近平面20,远平面 900。
let projectionMatrix = matrix.perspective(
fieldOfViewRadians,
window.innerWidth / window.innerHeight,
20,
900
);
4、设置 MVP 矩阵,执行渲染操作。
object.uniforms.u_Matrix = matrix.multiply(projectionMatrix, viewMatrix);
renderList.forEach(function(object){
...略
})
以上就是主要代码,我们看下贴图效果:
可以看到, 6 张图像都能正确的贴到对应的面上。接下来,我们将摄像机位置逐步放到立方体内部,看看呈现给我们的是什么。
在立方体内部观察。
在立方体内部观察时,呈现在我们面前的是天空盒的背面,但是由于背面剔除机制,我们是看不到背面的,所以这时候,我们要将背面剔除方式改成正面剔除
:
gl.cullFace(gl.FRONT);
上面的摄像机坐标是在Z轴正向 600 单位距离处,接下来我们将摄像机慢慢靠近立方体,当到达立方体中心后停止,我们看下这个过程。
可以看到,在内部观察时,会发现图像渲染是左右相反的,我们要修改一下顶点着色器,将纹理坐标的X值取反。
textCoord = vec3(-a_Position.x, a_Postion.y, a_Position.z);
这次大家会发现,在外面观察立方体时图像左右相反了,内部观察时正常了。我们这次做天空盒,就要在立方体内部观察,只要内部观察正常就可以。
我们将视线水平旋转进行观察。
target.x = Math.cos(deg2radians(uniforms.xRotation)) * Math.cos(deg2radians(uniforms.yRotation));
target.y = Math.sin(deg2radians(uniforms.xRotation));
target.z = Math.cos(deg2radians(uniforms.xRotation)) * Math.sin(deg2radians(uniforms.yRotation));
target.normalize();
// 重新计算视图矩阵
cameraMatrix = matrix.lookAt(cameraPosition, target, up);
viewMatrix = matrix.inverse(cameraMatrix);
效果如下:
以上天空盒的实现方式,我们用稍微真实一些的图片演示一下:
渲染时机
上面的例子中,我们只绘制了天空盒,没有绘制其他物体,实际应用中往往还有其他物体存在。当然,我们不能够让物体阻挡天空盒的渲染。
在上面的例子里,我们增加一个箱子,该箱子是长宽高均为 20 的立方体,上面的天空盒是长宽高为 500 的立方体,很明显,箱子在天空盒内部。
同时,我们的摄像机在坐标系原点位置,所以为了能够看到箱子,我们需要将箱子往后移至少 10 个单位长度,这里我们将它往后移动 100 个单位
let boxCube = createCube(20, 20, 20);
let boxBuffer = createBufferInfoFromObject(gl, boxCube);
...略
let box = new Model('box');
box.setBufferInfo(boxBuffer);
box.translateZ(-100);
此时包围盒和箱子的状态应该是这样的:
我们看下效果:
盒子显示在了天空盒前面,但是有一点不正常,就是我们看不到盒子的正面了。原因是什么呢?
在绘制天空盒的时候,摄像机在盒子内部,所以需要看到盒子背面,而箱子在摄像机外部,正常情况要能够看到盒子正面,所以在绘制箱子时,我们要把背面剔除方式改回背面剔除:
gl.cullFace(gl.BACK);
这回正常了。
有时候,天空盒经过视图投影矩阵作用之后,不一定在场景的最后面(也就是 Z 值坐标不是 1.0),所以,在天空盒后面的物体我们就看不到了。针对这个问题,一般采取的做法是修改顶点着色器,将天空盒的 Z 值强制改为 1.0,即在裁剪坐标系的最后面。
gl_Position = (u_Matrix * a_Position).xyww;
w/w = 1.0。所以经过裁剪变换后,gl_Position 的Z 值坐标始终会是 1.0。
那么,即便如此,也会有一些物体的 Z 值也是 1.0,同样是 1.0 的物体,哪个物体显示是根据绘制顺序决定的,因为深度测试函数默认是 gl.LESS,即当前片元的深度值要小于之前的深度值,才算测试通过,也就是能被渲染。所以,深度值是 1.0 的多个物体,哪个物体最先绘制,就渲染哪个物体。当然,我们可以改变深度测试函数。
针对种种规则,我们约定一个绘制顺序。
- 首先绘制物体(不包括天空盒)。
- 最后绘制天空盒,将天空盒的深度值和当前物体的深度值进行比较,如果通过深度测试就绘制天空盒。默认情况下,深度缓存为 1.0 表示深度最大,即在场景最后面。为了让天空盒能够通过深度测试,我们改变默认的深度测试函数,将
gl.LESS
改为gl.LEQUAL
,如下:
gl.depthFunc(gl.LEQUAL)
天空盒的应用
在实时场景渲染中,如果我们想要绘制非常远的物体,比如远山、天空等,当观察者往前或者往后移动时,远山、天空的大小是几乎没什么变化的。假如很远的地方有一座山,即使我们往前走进十米、百米,这座山在视野中的大小也是几乎没什么变化的,这个时候我们可以采用天空盒技术来实现。
又或者一些房屋装修、汽车内饰等只是为了展示环境的场景,这时也可以考虑采用天空盒技术。
回顾
本节讲述了立方体纹理的贴图过程以及天空盒的开发方式,涉及了前面的一些知识点,诸如背面剔除
、深度测试原理
等,如果我们对每一个知识点都能灵活运用的话,就能够实现很多强大的效果。