网易乐得技术团队

使用webGL绘图(二)

进入三维世界

使用webGL绘图(一)中的栗子都是二维的,但已经打好了通往三维世界的基石。从二维到三维,其实很简单,就是多了一个视图矩阵,仅此而已!如果想要透视效果,就是把正交投影矩阵换成透视投影矩阵就可以了。

下面举个栗子,画一个立方体:

顶点着色器:

1
2
3
4
5
attribute vec3 position;
uniform mat4 projection, view, model; // 投影、视图、模型矩阵
void main() {
gl_Position = projection * view * model * vec4(position, 1.0);
}

片元着色器:

1
2
3
4
5
precision mediump float;
uniform vec3 color;
void main() {
gl_FragColor = vec4(color, 1.0);
}

JavaScript:

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
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
import glNow from 'gl-now';
import { mat4 } from 'gl-matrix';
import createShader from 'gl-shader';
import createBuffer from 'gl-buffer';
import createVAO from 'gl-vao';
import create3DBox from 'geo-3d-box'; // 生成立方体顶点数据的函数
import vShader from './shader.vs';
import fShader from './shader.fs';
import createCamera from 'perspective-camera'; // 投影矩阵 * 视图矩阵
import createGemotry from 'gl-geometry'; // 替换vao/vbo

const shell = glNow({
clearColor: new Float32Array([0.0, 0.0, 0.0, 1.0]),
}),
cube = create3DBox({
size: 1,
segments: 1,
}),
startTime = new Date();

let shader, gemo, camera;

shell.on('gl-init', function() {
const gl = shell.gl;
initShader(gl); // 初始化着色器
initGemo(gl); // 初始化模型
initCamera(gl); // 初始化相机
});

shell.on('gl-render', function() {
const gl = shell.gl;
let time = new Date() - startTime;
draw(gl, time); // 绘制
});

shell.on('gl-resize', function() {
const gl = shell.gl;
if (camera) {
camera.viewport = [0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight];
camera.update();
}
});

shell.on('gl-error', function() {
throw new Error('不支持webGL');
});

function initShader(gl) {
shader = createShader(gl, vShader, fShader);
}

function initGemo(gl) {
gemo = createGemotry(gl)
.attr('position', cube.positions)
.faces(cube.cells);
}

function initCamera(gl) {
// 将(透视投影矩阵 * 视图矩阵)封装成相机,唯一决定一个视锥体
camera = createCamera({
fov: Math.PI / 2, // 相机观察角度范围
near: 0.01, // 近截面与相机距离
far: 100, // 远截面与相机距离
viewport: [0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight], // 宽高比

position: [0, 0, 2], // 相机位置
direction: [0, 0, -1], // 相机观察方向
up: [0, 1, 0], // 相机上方向,是视图矩阵的设置
});
camera.update(); // 更新相机
}

function draw(gl, time) {
// 模型矩阵
const deg = Math.sin(time / 1e3) * Math.PI;
const model = mat4.create();
mat4.rotate(model, model, deg, new Float32Array([1.0, 0.0, 1.0]));

// 绑定着色器并将顶点无关的数据传值给着色器
shader.bind();
shader.uniforms = {
projection: camera.projection, // 投影矩阵
view: camera.view, // 视图矩阵
model: model, // 模型矩阵
color: new Float32Array([1.0, 1.0, 1.0]),
};

// VAO将顶点数据传给着色器并绘图
gemo.bind(shader);
gemo.draw();
gemo.unbind();
}

代码简化了两处操作:

  • 使用gl-geometry模块来代替gl-buffergl-vao,以简化VBO、VAO的操作;
  • 使用perspective-camera来封装(透视投影矩阵 * 视图矩阵) ,可以称为相机,跟three.js里封装的相机是一回事;

主要看封装相机的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function initCamera(gl) {
// 将(透视投影矩阵 * 视图矩阵)封装成相机,唯一决定一个视锥体
camera = createCamera({
fov: Math.PI / 2, // 相机观察角度范围
near: 0.01, // 近截面与相机距离
far: 100, // 远截面与相机距离
viewport: [0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight], // 宽高比

position: [0, 0, 2], // 相机位置
direction: [0, 0, -1], // 相机观察方向
up: [0, 1, 0], // 相机上方向,是视图矩阵的设置
});
camera.update(); // 更新相机
}

其实这里的camera就是透视投影矩阵乘以视图矩阵,传的参数就是为了唯一构建一个视锥体,就像正交投影的视景体一样,只是一个是锥形的,一个是矩形的。视锥体远截面缩小得和近截面一般大小,就成了正交投影的视景体,此时视景体内的物体也会跟着变形,然后平行投射到近截面上生成2D图形,就有了近大远小的透视效果。

投影

全局对象camera可以拿到投影矩阵和视图矩阵,然后赋值给顶点着色器的相关变量,参与顶点坐标的计算:

1
2
3
4
5
6
7
shader.bind();
shader.uniforms = {
projection: camera.projection, // 投影矩阵
view: camera.view, // 视图矩阵
model: model, // 模型矩阵
color: new Float32Array([1.0, 1.0, 1.0]),
};

最终渲染效果如下:
立方体

可以看出,单纯给立方体一个颜色,无法区分它的表面,现实生活中,都是因为光照才让我们区分物体的不同表面。

光照

webGL的API非常底层,它是没有光照概念的,需要通过着色器来模拟出来。之前介绍过纹理贴图,我们也可以制作一张光照效果的纹理贴到物体表面上来模拟光照。但如果物体是运动的或光源是运动的,那么通过纹理贴图的方式就不适合了。现实世界的光照是非常复杂的,大部分情况下不需要那么逼真的模拟,只需要做很简单的模拟,就已经能达到很逼真的效果了。光照是咋模拟的呢?不同的光需要通过不同的算法来实现,下面以点光源为例,来简单说明一下,请看下面的图:

点光源

我们需要在片元着色器中按某种算法计算出每个像素的颜色,来模拟点光源照射的效果。如图所示,光源照射到立方体表面不同位置的入射角是不一样的,最大的入射角是90度,此时反射的光线也最强烈,最小的入射角为0或负数,都可以看成0,此时没有反射光线。通过入射角的大小来决定立方体表面上每个像素的颜色,入射角大的像素给较亮的颜色,入射角小的像素给较暗的颜色,这样就简单模拟了点光源光照的效果。

光照的算法涉及到向量运算,毕竟有点复杂,不想自己写,想用现成别人写好的,该怎么办呢?幸好stack.gl里能找到模块化着色器的工具glslify,也可以找到很多模拟光照的模块,我们可以直接拿来用。

下面请看示例代码:

mesh.vs

1
2
3
4
5
6
7
8
9
10
11
12
13
#pragma glslify: transpose = require(glsl-transpose) // 导入转置矩阵方法模块
#pragma glslify: inverse = require(glsl-inverse) // 导入逆矩阵方法模块

attribute vec3 position, normal; // 顶点坐标、法向量
uniform mat4 projection, view, model; // 投影、视图、模型矩阵
varying vec3 v_position, v_normal; // 需要传给片元着色器的顶点坐标、法向量

void main() {
mat3 _matrix = transpose(inverse(mat3(model))); // 计算模型矩阵的逆转置矩阵
gl_Position = projection * view * model * vec4(position, 1.0); // 计算最终顶点坐标
v_position = mat3(model) * position; // 传给片元着色器运动物体的顶点坐标
v_normal = _matrix * normal; // 传给片元着色器运动物体的法向量
}

mesh.fs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
precision mediump float;
#pragma glslify: lambert = require(glsl-diffuse-lambert) // 导入光照函数

uniform vec3 lightPosition, color; // 光源位置,物体颜色
varying vec3 v_position, v_normal; // 顶点着色器传入的顶点坐标和法向量,被内插成每一像素的坐标和法向量

void main() {
vec3 lightDirection = normalize(lightPosition - v_position); // 规范化的光照射方向向量
vec3 normal = normalize(v_normal); // 规范化的像素法向量
float power = lambert(lightDirection, normal); // 光照后颜色通道的权重
vec3 ambientLight = vec3(0.2, 0.2, 0.2); // 加一点环境光,否则没有光照射到的地方是纯黑的
vec3 lightColor = vec3(power, power, power) + ambientLight; // 光的颜色
gl_FragColor = vec4(color * lightColor, 1.0);
}

index.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
65
66
67
68
import glNow from 'gl-now';
import createCamera from 'perspective-camera';
import { mat4, vec3 } from 'gl-matrix';
import Cube from './Cube'; // 立方体
import LightPoint from './LightPoint'; // 光源用点表示

const shell = glNow({
clearColor: new Float32Array([0.0, 0.0, 0.0, 1.0]),
}),
startTime = new Date();

let camera, cube, lightPoint;

shell.on('gl-init', function() {
const gl = shell.gl;
gl.enable(gl.DEPTH_TEST); // 打开深度测试,否则显示顺序是按绘制的顺序,而不是深度。
gl.enable(gl.CULL_FACE); // 开启反面消除(默认),提高性能

camera = createCamera({
fov: Math.PI / 2,
near: 0.1,
far: 100,
viewport: [0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight],
position: [0, 0, 2],
direction: [0, 0, -1],
up: [0, 1, 0],
});

cube = new Cube(gl);
lightPoint = new LightPoint(gl);
});

shell.on('gl-render', function() {
const gl = shell.gl;
const time = new Date() - startTime;

// 运动的光源
const deg = (time / 1e3) % (Math.PI * 2);
const r = 1;
const lightPosition = new Float32Array([
r * Math.cos(deg),
r * Math.cos(deg),
r * Math.sin(deg)
]);

// 绘制立方体
cube.lightPosition = lightPosition;
cube
.rotate(deg, new Float32Array([1.0, 0.0, 1.0]))
.draw(gl, camera);

// 绘制点光源
lightPoint
.translate(lightPosition)
.draw(gl, camera);
});

shell.on('gl-resize', function() {
const gl = shell.gl;
if (camera) {
camera.viewport = [0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight];
camera.update();
}
});

shell.on('gl-error', function() {
throw new Error('不支持webGL');
});

Cube.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
65
66
67
68
import createShader from 'gl-shader';
import glslify from 'glslify'; // 着色器代码模块化工具
import createGeometry from 'gl-geometry';
import create3DBox from 'geo-3d-box';
import vShader from './shader/mesh.vs';
import fShader from './shader/mesh.fs';
import { mat4, vec3 } from 'gl-matrix';

const transfromStack = [];

export default class Cube {

constructor(gl) {
const mesh = create3DBox({
size: 1,
segments: 1,
});
this.shader = createShader(gl, glslify(vShader), glslify(fShader));
this.gemo = createGeometry(gl)
.attr('position', mesh.positions)
.attr('normal', mesh.normals)
.faces(mesh.cells);
this.model = mat4.create();
this.lightPosition = vec3.create();
}

draw(gl, camera) {
this.shader.bind();
this.shader.uniforms = {
projection: camera.projection,
view: camera.view,
model: this.model,
color: new Float32Array([1.0, 1.0, 1.0]),
lightPosition: this.lightPosition,
};

mat4.identity(this.model);
while (transfromStack.length > 0) {
(transfromStack.pop())();
}

this.gemo.bind(this.shader);
this.gemo.draw();
this.gemo.unbind();
}

translate(vector) {
transfromStack.push(() => {
mat4.translate(this.model, this.model, vector);
});
return this;
}

rotate(deg, vector) {
transfromStack.push(() => {
mat4.rotate(this.model, this.model, deg, vector);
});
return this;
}

scale(vector) {
transfromStack.push(() => {
mat4.scale(this.model, this.model, vector);
});
return this;
}

}

LightPoint.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
import createShader from 'gl-shader';
import createGeometry from 'gl-geometry';
import { mat4 } from 'gl-matrix';

const
vShader = `
attribute vec3 positon;
uniform mat4 projection, view, model;
void main() {
gl_Position = projection * view * model * vec4(positon, 1.0);
gl_PointSize = 10.0;
}
`,

fShader = `
precision mediump float;
uniform vec3 color;
void main() {
gl_FragColor = vec4(color, 1.0);
}
`,

transfromStack = [];

export default class LightPoint {

constructor(gl) {
this.shader = createShader(gl, vShader, fShader);
this.gemo = createGeometry(gl)
.attr('position', [[0.0, 0.0, 0.0]]);
this.model = mat4.create();
}

draw(gl, camera) {
this.shader.bind();
this.shader.uniforms = {
projection: camera.projection,
view: camera.view,
model: this.model,
color: new Float32Array([1.0, 0.0, 0.0]),
};

mat4.identity(this.model);
while (transfromStack.length > 0) {
const transformFn = transfromStack.pop();
transformFn();
}

this.gemo.bind(this.shader);
this.gemo.draw(gl.POINTS);
this.gemo.unbind();
}

translate(vector) {
transfromStack.push(() => {
mat4.translate(this.model, this.model, vector);
});
return this;
}

}

代码将立方体和点光源作为模块分别封装在不同的文件里,index.js调用这两个模块分别进行绘制。需要注意的是,绘制三维物体一定要开启深度测试 gl.enable(gl.DEPTH_TEST),这样webGL系统才会根据深度来判断哪些像素该渲染,哪些像素不该渲染。

在立方体的顶点着色器中,需要把顶点坐标和法向量传给片元着色器,因为物体是运动的,当物体运动后,顶点坐标和法向量都会跟着改变,为了得到正确的顶点坐标和法向量,需要通过一个算法来实现,即用模型矩阵的逆转置矩阵乘以它们就可以了。所以在顶点着色器中引入了求逆矩阵和转置矩阵的模块,并计算出模型矩阵的逆转置矩阵,然后将计算好的顶点坐标和法向量传给片元着色器。

在立方体的片元着色器中,导入光照函数lambert,接收顶点着色器传来的顶点坐标和法向量并内插成逐像素的坐标和法向量,然后计算光照方向向量,并和法向量一道传入光照函数,得到一个介于0和1之前的权重值power,用来和RGB每个颜色分量相乘得到最终颜色。最终效果如下(红色的小方块是点光源的位置):

模型光照效果

综合使用纹理、光照

纹理和光照,最终都归结为给片元着色器里每个像素赋颜色值。纹理是将图片的纹素(像素颜色)逐个提取出来贴到片元上,光照是通过某种算法计算颜色值赋给片元。下面是一个综合使用纹理和光照的栗子:

model.vs

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
#pragma glslify: transpose = require('glsl-transpose')
#pragma glslify: inverse = require('glsl-inverse')

attribute vec3 position, normal;
attribute vec2 uv;

uniform mat4 projection, view, model;

varying vec3 v_position, v_normal;
varying vec2 v_uv;

void main() {
gl_Position = projection * view * model * vec4(position, 1.0);

// 像素坐标
v_position = vec3(model * vec4(position, 1.0));

// 像素法向量
mat3 normalMatrix = transpose(inverse(mat3(model)));
v_normal = normalize(normalMatrix * normal);

// 像素纹理坐标
vec2 UV_SCALE = vec2(4.0, 1.0);
v_uv = uv * UV_SCALE;
}

model.fs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
precision mediump float;
#pragma glslify: phongSpec = require(glsl-specular-phong/index.glsl)

uniform sampler2D texture;
uniform vec3 lightPosition, eyePosition;
uniform float shininess;

varying vec3 v_position, v_normal;
varying vec2 v_uv;

void main() {
// 计算片元光照
vec3 eyeDirection = normalize(eyePosition - v_position);
vec3 lightDirection = normalize(lightPosition - v_position);
float power = phongSpec(lightDirection, eyeDirection, v_normal, shininess);

vec4 textureColor = texture2D(texture, v_uv); // 纹理颜色

vec3 ambientLight = vec3(0.3, 0.3, 0.3); // 环境光颜色
vec3 lightColor = vec3(power, power, power) + ambientLight; // 光照颜色

gl_FragColor = vec4(vec3(textureColor) * lightColor, 1.0); // 计算最终颜色。
}

index.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
import glNow from 'gl-now';
import createCamera from 'perspective-camera';
import { mat4, vec3 } from 'gl-matrix';
import Trous from './Trous'; // 圆环体
import LightPoint from './LightPoint'; // 点光源

const shell = glNow({
clearColor: new Float32Array([0.0, 0.0, 0.0, 1.0]),
}),
startTime = new Date();

let camera, trous, lightPoint;

shell.on('gl-init', function() {
const gl = shell.gl;
gl.enable(gl.DEPTH_TEST); // 打开深度测试,否则显示顺序是按绘制的顺序,而不是深度。
gl.enable(gl.CULL_FACE); // 开启反面消除(默认),提高性能

camera = createCamera({
fov: Math.PI / 2,
near: 0.1,
far: 100,
viewport: [0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight],
position: [0, 0, 2],
direction: [0, 0, -1],
up: [0, 1, 0],
});

trous = new Trous(gl);
lightPoint = new LightPoint(gl);
});

shell.on('gl-render', function() {
const gl = shell.gl;
const time = new Date() - startTime;

const deg = (time / 5e3) % (Math.PI * 2);
const r = 1;
const lightPosition = new Float32Array([r * Math.sin(deg), 0, 0]);

trous.lightPosition = lightPosition;
trous
.rotate(deg, new Float32Array([0.0, 1.0, 0.0]))
.draw(gl, camera);

lightPoint
.translate(lightPosition)
.draw(gl, camera);
});

shell.on('gl-resize', function() {
const gl = shell.gl;
if (camera) {
camera.viewport = [0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight];
camera.update();
}
});

shell.on('gl-error', function() {
throw new Error('不支持webGL');
});

Trous.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
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
import { mat4, vec3 } from 'gl-matrix';
import createShader from 'gl-shader';
import glslify from 'glslify';
import createGeometry from 'gl-geometry';
import createTexture from 'gl-texture2d';
import createTorus from 'primitive-torus'; // 圆环模型
import vShader from './shader/model.vs';
import fShader from './shader/model.fs';
import imageUrl from '../common/img/brick-diffuse.jpg';
import { loadImage } from '../common/tools.js';

const transfromStack = [];

export default class Trous {

constructor(gl) {
this.shader = createShader(gl, glslify(vShader), glslify(fShader));

const torus = createTorus();
this.gemo = createGeometry(gl)
.attr('position', torus.positions)
.attr('uv', torus.uvs, { size: 2 })
.attr('normal', torus.normals)
.faces(torus.cells);

this.model = mat4.create();
this.lightPosition = vec3.create();

(async () => {
const img = await loadImage(imageUrl);
this.texture = createTexture(gl, img);
this.texture.wrap = gl.REPEAT;

// 金字塔纹理
this.texture.generateMipmap(gl.TEXTURE_2D);
this.texture.minFilter = gl.LINEAR_MIPMAP_LINEAR;
this.texture.magFilter = gl.LINEAR;

gl.bindTexture(gl.TEXTURE_2D, null);
})();
}

draw(gl, camera) {

// 执行模型变换
mat4.identity(this.model);
while (transfromStack.length > 0) {
const transformFn = transfromStack.pop();
transformFn();
}

this.shader.bind();
this.shader.uniforms = {
projection: camera.projection,
view: camera.view,
model: this.model,
lightPosition: this.lightPosition,
eyePosition: camera.position,
shininess: 0.5,
};
if (this.texture) {
this.texture.bind();
this.shader.uniforms.texture = this.texture;
}

this.gemo.bind(this.shader);
this.gemo.draw();
this.gemo.unbind();
gl.bindTexture(gl.TEXTURE_2D, null);
}

translate(vector) {
transfromStack.push(() => {
mat4.translate(this.model, this.model, vector);
});
return this;
}

rotate(deg, vector) {
transfromStack.push(() => {
mat4.rotate(this.model, this.model, deg, vector);
});
return this;
}

scale(vector) {
transfromStack.push(() => {
mat4.scale(this.model, this.model, vector);
});
return this;
}

}

LightPoint.js

1
// 同上面的 LightPoint.js

从片元着色器能够看出,逐像素计算出纹理颜色和光照颜色,然后相乘,得到最终颜色。最终效果如下:

纹理与光照

天空盒

在虚拟场景中模拟天空常用的方法有天空顶(SkyDome:半球形)和天空盒(SkyBox:长方体)两种方法,这里简单介绍一下webGL如何绘制天空盒。天空盒的原理很简单,就是给立方体的六个面贴上合适的纹理,就可以产生天空的效果了。可以使用2d纹理来实现,但webGL有贴立方体纹理的功能,所以这里介绍下立方体纹理。

立方体纹理

前面介绍的2D纹理通过x,y两个坐标采样,而立方体纹理有x,y,z三个坐标,它是如何采样的呢?一图以蔽之:

立方体贴图

立方体纹理的采样过程:

  1. 首先提供上下左右前后六幅2d纹理,它们贴满立方体的每个面,就像图中的立方体一样,该立方体只是个采样器,并不会被绘制出来;
  2. 假想图中立方体的内部有个球体,该球体是要被绘制出来的,我们要给该球体表面贴图;
  3. 从坐标原点开始,发射一条射线,穿过球体表面像素a和立方体表面像素b,b的颜色会赋值给a,这样就完成了球体表面单个像素颜色的采样。

下面给出一个栗子:

sky.vs

1
2
3
4
5
6
7
8
attribute vec3 position;
uniform mat4 projection, view, model;
varying vec3 v_uv;

void main() {
gl_Position = projection * view * model * vec4(position, 1.0);
v_uv = position; // 立方体纹理的坐标就是“初始的顶点坐标”
}

sky.fs

1
2
3
4
5
6
7
8
precision mediump float;

uniform samplerCube texture;
varying vec3 v_uv;

void main() {
gl_FragColor = textureCube(texture, v_uv);
}

index.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
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
import glNow from 'gl-now';
import { mat4 } from 'gl-matrix';
import createNormals from 'normals'; // 用于计算法向量
import createShader from 'gl-shader';
import createGeometry from 'gl-geometry';
import createCamera from 'perspective-camera';
import createTextureCube from 'gl-texture-cube'; // 用于构建立方纹理
import create3DBox from 'geo-3d-box';
import skyVertexShader from '../common/shader/sky.vs';
import skyFragmentShader from '../common/shader/sky.fs';
import { loadImageList } from '../common/tools.js';

// 天空盒是个正六面体,有六张图做纹理
import skyImgUrlPosX from '../common/img/sky/pos-x.jpg'; // 右(x正轴)
import skyImgUrlPosY from '../common/img/sky/pos-y.jpg'; // 上(y正轴)
import skyImgUrlPosZ from '../common/img/sky/pos-z.jpg'; // 前(z正轴)
import skyImgUrlNegX from '../common/img/sky/neg-x.jpg'; // 左(x负轴)
import skyImgUrlNegY from '../common/img/sky/neg-y.jpg'; // 下(y负轴)
import skyImgUrlNegZ from '../common/img/sky/neg-z.jpg'; // 后(z负轴)

const shell = glNow({
clearColor: new Float32Array([0.0, 0.0, 0.0, 1.0]),
}),
cube = create3DBox({
size: 20,
segments: 1,
}),
startTime = new Date();

let shader, gemo, camera, texture;

shell.on('gl-init', function() {
const gl = shell.gl;
gl.enable(gl.CULL_FACE);
gl.enable(gl.DEPTH_TEST);
// gl.cullFace(gl.FRONT); // 天空盒正面剔除,反面显示

initShader(gl);
initGemo(gl);
initCamera(gl);
initTexture(gl);
});

shell.on('gl-render', function() {
const gl = shell.gl,
time = new Date() - startTime;

draw(gl, time);
});

shell.on('gl-resize', function() {
const gl = shell.gl;
if (camera) {
camera.viewport = [0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight];
camera.update();
}
});

shell.on('gl-error', function() {
throw new Error('不支持webGL');
});

function initShader(gl) {
shader = createShader(gl, skyVertexShader, skyFragmentShader);
}

function initGemo(gl) {
gemo = createGeometry(gl)
.attr('position', cube.positions)
.faces(cube.cells);
}

function initCamera(gl) {
camera = createCamera({
fov: Math.PI / 3,
near: 0.1,
far: 100.0,
viewport: [0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight],

position: [0.0, 30.0, 50.0],
// position: [0.0, 0.0, 5.0], // 注意要开启背面剔除
direction: [0, -3, -5],
up: [0, 1, 0],
});
camera.update();
}

async function initTexture(gl) {
// 加载天空盒六张图片
const imgList = await loadImageList([
skyImgUrlPosX, skyImgUrlPosY, skyImgUrlPosZ,
skyImgUrlNegX, skyImgUrlNegY, skyImgUrlNegZ
]);
// 创建立方纹理贴图
texture = createTextureCube(gl, {
pos: {
x: imgList[0],
y: imgList[1],
z: imgList[2],
},
neg: {
x: imgList[3],
y: imgList[4],
z: imgList[5],
},
});

// 金字塔纹理
texture.generateMipmap(gl.TEXTURE_CUBE_MAP);
texture.minFilter = gl.LINEAR_MIPMAP_LINEAR;
texture.magFilter = gl.LINEAR;
}

function draw(gl, time) {
// 加点动画
const model = mat4.create();
const deg = Math.sin(time / 10e3) * Math.PI;
mat4.rotate(model, model, deg, new Float32Array([0.0, 1.0, 0.0]));

// uniform传给着色器
shader.bind();
shader.uniforms = {
projection: camera.projection,
view: camera.view,
model: model,
};
if (texture) {
shader.uniforms.texture = texture;
}

// attribute传给着色器并触发着色器执行
gemo.bind(shader);
gemo.draw();
gemo.unbind();
}

顶点着色器将顶点坐标作为纹理坐标传给片元着色器,片元着色器中接收立方体纹理采样器和纹理坐标,然后逐片元采样。
js代码加载了六幅图,从命名可以看出,分别表示立方体x,y,z正反轴的六个面。可以看下图的对照:

立方体纹理图示1

立方体纹理图示2

使用gl-texture-cube模块,将六幅图按照既定的格式传进去,就得到了立方体纹理采样器,然后传给片元着色器。最终绘制的效果如下:

天空盒外部

我们看到了一个贴有纹理的立方体,只要把相机的位置移到立方体里面,然后开启正面消除,背面显示,就可以看到天空盒效果了:

天空盒内部