Skip to content

在初级进阶课程,我们学习了纹理的绘制方法,不过那节我们只学习了如何使用2D纹理进行贴图。事实上,WebGL 还支持将多个 2D 纹理组合成一个单一纹理,然后采用该单一纹理进行贴图,这种纹理称为立方体纹理CubeMap,本节我们学习如何使用立方体纹理进行贴图。

采样原理

在讲解贴图方法之前,我们简单了解一下立方体纹理的采样原理。立方体纹理实质上由 6 个 2D 纹理组成,这 6 个2D 纹理对应立方体的每个面,立方体纹理可以理解为 6 个面都是图像的立方体。

那如何对立方体纹理进行采样呢?首先,立方体纹理对应的 6 个图像资源宽度和高度要相等。其次,使用一束从立方体中心位置发出的方向向量进行采样,方向向量就是我们传递给 GPU 的纹理坐标,该向量和立方体交点处的图像像素就是我们需要的采样值。

当模型是单位为 1 的立方体时,模型的顶点坐标即立方体纹理坐标。

贴图过程

立方体纹理的贴图过程和之前的 2D 纹理贴图有所区别,接下来我们演示一下。

着色器

因为是纹理操作,所以着色器部分需要增加纹理坐标和纹理资源。

顶点着色器

顶点着色器需要增加纹理坐标,立方体纹理的一个优点就是纹理坐标采用原始坐标值就可以。

glsl
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 作为像素颜色。

glsl
precision mediump float;
//接收纹理坐标坐标 (x, y, z)
varying vec3 textCoord;
//samplerCube变量,用来接收立方体纹理。
uniform samplerCube u_Skybox;

void main()
{
    gl_FragColor = textureCube(u_Skybox, textCoord);
}

加载资源

和 2D 纹理一样,在使用纹理之前,我们还是要先把资源加载到内存中,由于立方体纹理需要 6 个图像,所以我们需要定义一个图像资源加载器,它包含一个资源列表和加载完成后的回调事件。

javascript
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];
    }
    
}

有了加载器,我们将需要的六张图片加载到内存中。

javascript
loadImages(['right.png','left.png','top.png','down.png','back.png','front.png'], renderSkyBox);

renderSkyBox是图片加载完成后执行的回调,也就是下面的绘制过程。

纹理操作

图片资源加载到内存之后,我们就可以对纹理进行操作了。

1、创建纹理对象,并激活 0 号纹理单元。

javascript
//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

javascript
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、设置图片放大缩小时的过滤参数,此处我们设置为就近取值。

javascript
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 表示采用边缘的纹理像素。

javascript
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 的立方体,并将立方体转化成无索引形式。

边长长度没有限制,只要保证摄像机位置在立方体内部即可。

javascript
//索引立方体
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、创建摄像机

javascript
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。

javascript
let projectionMatrix = matrix.perspective(
    fieldOfViewRadians,
    window.innerWidth / window.innerHeight,
    20,
    900
);

4、设置 MVP 矩阵,执行渲染操作。

javascript
object.uniforms.u_Matrix = matrix.multiply(projectionMatrix, viewMatrix);

renderList.forEach(function(object){
    ...
})

以上就是主要代码,我们看下贴图效果:

可以看到, 6 张图像都能正确的贴到对应的面上。接下来,我们将摄像机位置逐步放到立方体内部,看看呈现给我们的是什么。

在立方体内部观察。

在立方体内部观察时,呈现在我们面前的是天空盒的背面,但是由于背面剔除机制,我们是看不到背面的,所以这时候,我们要将背面剔除方式改成正面剔除

gl.cullFace(gl.FRONT);

上面的摄像机坐标是在Z轴正向 600 单位距离处,接下来我们将摄像机慢慢靠近立方体,当到达立方体中心后停止,我们看下这个过程。

可以看到,在内部观察时,会发现图像渲染是左右相反的,我们要修改一下顶点着色器,将纹理坐标的X值取反。

javascript
textCoord = vec3(-a_Position.x, a_Postion.y, a_Position.z);

这次大家会发现,在外面观察立方体时图像左右相反了,内部观察时正常了。我们这次做天空盒,就要在立方体内部观察,只要内部观察正常就可以。

我们将视线水平旋转进行观察。

javascript
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 个单位

javascript
let boxCube = createCube(20, 20, 20);
let boxBuffer = createBufferInfoFromObject(gl, boxCube);
...

let box = new Model('box');
box.setBufferInfo(boxBuffer);
box.translateZ(-100);

此时包围盒和箱子的状态应该是这样的:

我们看下效果:

盒子显示在了天空盒前面,但是有一点不正常,就是我们看不到盒子的正面了。原因是什么呢?

在绘制天空盒的时候,摄像机在盒子内部,所以需要看到盒子背面,而箱子在摄像机外部,正常情况要能够看到盒子正面,所以在绘制箱子时,我们要把背面剔除方式改回背面剔除:

javascript
gl.cullFace(gl.BACK);

这回正常了。

有时候,天空盒经过视图投影矩阵作用之后,不一定在场景的最后面(也就是 Z 值坐标不是 1.0),所以,在天空盒后面的物体我们就看不到了。针对这个问题,一般采取的做法是修改顶点着色器,将天空盒的 Z 值强制改为 1.0,即在裁剪坐标系的最后面。

glsl
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,如下:
javascript
gl.depthFunc(gl.LEQUAL)

天空盒的应用

在实时场景渲染中,如果我们想要绘制非常远的物体,比如远山、天空等,当观察者往前或者往后移动时,远山、天空的大小是几乎没什么变化的。假如很远的地方有一座山,即使我们往前走进十米、百米,这座山在视野中的大小也是几乎没什么变化的,这个时候我们可以采用天空盒技术来实现。

又或者一些房屋装修、汽车内饰等只是为了展示环境的场景,这时也可以考虑采用天空盒技术。

回顾

本节讲述了立方体纹理的贴图过程以及天空盒的开发方式,涉及了前面的一些知识点,诸如背面剔除深度测试原理等,如果我们对每一个知识点都能灵活运用的话,就能够实现很多强大的效果。