在使用灯光之前,首先我们需要了解,与定义更广泛的 OpenGL 不同,WebGL 并没有继承 OpenGL 中灯光的支持。所以你只能由自己完全得控制灯光。幸运得是,这也并不是很难,本文接下来就会介绍完成灯光的基础。
在 3D 空间中模拟现实灯光
在 3D 空间中模拟现实世界的灯光的具体原理和细节绝非本篇文章能够描述清楚的,但是对灯光模型有一定的了解对我们的学习还是很有帮助的。虽然这里没办法深入讲解,但是维基百科中的Phong 着色法给出了一个不错的概要介绍,其中包含了最常用的几种光照模型。
光源类型可以概括成如下三种:
环境光 是一种可以渗透到场景的每一个角落的光。它是非方向光并且会均匀地照射物体的每一个面,无论这个面是朝向哪个方向的。
方向光 是一束从一个固定的方向照射过来的光。这种光的特点可以理解为好像是从一个很遥远的地方照射过来的,然后光线中的每一个光子与其他光子都是平行运动的。举个例子来说,阳光就可以认为是方向光。
点光源光 是指光线是从一个点发射出来的,是向着四面八方发射的。这种光在我们的现实生活中是最常被用到的。举个例子来说,电灯泡就是向各个方向发射光线的。
以我们的需要来看,我们会简化光照模型,只考虑简单的方向光和环境光,不会考虑任何镜面反射和点光源。这样的话,我们只需要在我们使用的环境光上加上照射到旋转立方体的方向光就可以了。在这里可以看到之前的旋转立方体的例子。
虽然可以抛开了点光源和镜面反射,但是关于方向光还是有两点需要注意一下:
需要在每个顶点信息中加入面的朝向法线。这个法线是一个垂直于这个顶点所在平面的向量。
需要明确方向光的传播方向,可以使用一个方向向量来定义。
接着,我们会更新顶点着色器,考虑到环境光,再考虑到方向光(方向光的作用会因为光线方向与面的夹角关系而不同),计算每一个顶点的颜色。实现这一目标的代码如下。
建立顶点法线
首先我们需要做的是建立一个数组来存放立方体所有顶点的法线。由于立方体是一个很简单的物体,所以很容易实现;显然如果是对复杂物体,则法线的计算方法需要更深入的研究。(注:译者调试后发现此处 new WebGLFloatArray(...)
可能应该使用 new Float32Array())
。
javascript
cubeVerticesNormalBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, cubeVerticesNormalBuffer);
var vertexNormals = [
// Front
0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0,
// Back
0.0, 0.0, -1.0, 0.0, 0.0, -1.0, 0.0, 0.0, -1.0, 0.0, 0.0, -1.0,
// Top
0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0,
// Bottom
0.0, -1.0, 0.0, 0.0, -1.0, 0.0, 0.0, -1.0, 0.0, 0.0, -1.0, 0.0,
// Right
1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0,
// Left
-1.0, 0.0, 0.0, -1.0, 0.0, 0.0, -1.0, 0.0, 0.0, -1.0, 0.0, 0.0,
];
gl.bufferData(
gl.ARRAY_BUFFER,
new WebGLFloatArray(vertexNormals),
gl.STATIC_DRAW,
);
现在我们应该对此非常熟悉了;创建新的 buffer,将它和 gl.ARRAR_BUFFER
绑定在一起,然后通过调用 bufferData()
把我们的顶点法线数组一起传入。
然后我们在 drawScene()
中添加代码,将法线数组和着色器的 attribute 绑定起来以便着色器能够获取到法线数组的信息。
(此处变量 vertexNormalAttribute 应该在 initShader()
函数中声明,并赋值:vertexNormalAttribute = gl.getAttribLocation(shaderProgram, "aVertexNormal"); gl.enableVertexAttribArray(vertexNormalAttribute);)
javascript
gl.bindBuffer(gl.ARRAY_BUFFER, cubeVerticesNormalBuffer);
gl.vertexAttribPointer(vertexNormalAttribute, 3, gl.FLOAT, false, 0, 0);
最后,我们(此处代码应该在 setMatrixUniforms()
函数中添加)需要更新下代码,在着色器中建立和传递法线向量矩阵,用这个矩阵来处理当前立方体相对于光源位置法线向量的转换 (注:译者调试后发现此处 new WebGLFloatArray(...)
应该使用 new Float32Array()
):
javascript
var normalMatrix = mvMatrix.inverse();
normalMatrix = normalMatrix.transpose();
var nUniform = gl.getUniformLocation(shaderProgram, "uNormalMatrix");
gl.uniformMatrix4fv(
nUniform,
false,
new WebGLFloatArray(normalMatrix.flatten()),
);
更新着色器
现在着色器需要的所有数据已经全部可以获取到了(或者说全部准备好了),我们需要更新下着色器本身的代码。
顶点着色器
首先更新顶点着色器,让它给每一个基于环境光和方向光的顶点一个着色器值。让我们看下代码:
javascript
<script id="shader-vs" type="x-shader/x-vertex">
// 定义了一个顶点属性aVertexNormal,用于存储顶点的法向量信息。
attribute highp vec3 aVertexNormal;
// 定义了一个顶点属性aVertexPosition,用于存储顶点的位置信息。
attribute highp vec3 aVertexPosition;
// 定义了一个顶点属性aTextureCoord,用于存储顶点的纹理坐标信息
attribute highp vec2 aTextureCoord;
// 定义了一个uniform变量uNormalMatrix,用于传递法向量矩阵。
uniform highp mat4 uNormalMatrix;
// 定义了一个uniform变量uMVMatrix,用于传递模型视图矩阵。
uniform highp mat4 uMVMatrix;
// 定义了一个uniform变量uPMatrix,用于传递投影矩阵
uniform highp mat4 uPMatrix;
// 在顶点着色器和片元着色器之间传递纹理坐标信息
varying highp vec2 vTextureCoord;
// 用于在顶点着色器和片元着色器之间传递光照信息
varying highp vec3 vLighting;
void main(void) {
gl_Position = uPMatrix * uMVMatrix * vec4(aVertexPosition, 1.0);
vTextureCoord = aTextureCoord;
// 定义了环境光、定向光的颜色和方向。
highp vec3 ambientLight = vec3(0.6, 0.6, 0.6);
highp vec3 directionalLightColor = vec3(0.5, 0.5, 0.75);
highp vec3 directionalVector = vec3(0.85, 0.8, 0.75);
// 将法向量乘以法向量矩阵uNormalMatrix得到变换后的法向量transformedNormal
highp vec4 transformedNormal = uNormalMatrix * vec4(aVertexNormal, 1.0);
// 这里计算了法向量与光线方向向量之间的点积,点积结果表示了法向量和光线的夹角关系,值域为[-1, 1]
// 使用max函数确保夹角余弦值不会为负数,如果夹角余弦值小于0,则将其设为0,表示光线不会照射到背对光源的表面
highp float directional = max(dot(transformedNormal.xyz, directionalVector), 0.0);
// 最终的光照效果通过将环境光和定向光的效果相加得到,乘以定向光的强度directional,从而得到最终的光照效果
vLighting = ambientLight + (directionalLightColor * directional);
}
</script>
一旦顶点位置计算完毕,我们就可以获得纹理对应于顶点的坐标,从而计算出顶点的阴影。
我们先根据立方体位置和朝向,通过顶点法线乘以法线矩阵来转换法线。接着我们可以通过计算转换过后的法线与方向向量(即,光来自的方向)的点积来计算得出顶点反射方向光的量。如果计算出的这个值小于 0,则我们把值固定设为 0,因为你不会有小于 0 的光。
当方向光的量计算完,我们可以通过获取环境光并且添加方向光的颜色和要提供的定向光的量来生成光照值(lighting value)。最终结果我们会得到一个 RGB 值,用于片段着色器调整我们渲染的每一个像素的颜色。
片段着色器
片段着色器现在需要根据顶点着色器计算出的光照值来更新:
javascript
<script id="shader-fs" type="x-shader/x-fragment">
varying highp vec2 vTextureCoord;
varying highp vec3 vLighting;
uniform sampler2D uSampler;
// 从纹理中获取的颜色与光照效果相乘,得到受光照影响的最终颜色,并将其作为片元的最终颜色输出
void main(void) {
// 这里从纹理uSampler中获取纹理坐标为(vTextureCoord.s, vTextureCoord.t)处的颜色
mediump vec4 texelColor = texture2D(uSampler, vec2(vTextureCoord.s, vTextureCoord.t));
// 将从纹理中获取的颜色与之前计算的光照效果vLighting相乘,以获得最终的受光照影响的颜色
gl_FragColor = vec4(texelColor.rgb * vLighting, texelColor.a);
}
</script>
源码如下:
javascript
// init-buffers.js
function initBuffers(gl) {
const positionBuffer = initPositionBuffer(gl);
const textureCoordBuffer = initTextureBuffer(gl);
const indexBuffer = initIndexBuffer(gl);
const normalBuffer = initNormalBuffer(gl);
return {
position: positionBuffer,
normal: normalBuffer,
textureCoord: textureCoordBuffer,
indices: indexBuffer,
};
}
function initPositionBuffer(gl) {
// Create a buffer for the square's positions.
const positionBuffer = gl.createBuffer();
// Select the positionBuffer as the one to apply buffer
// operations to from here out.
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
const positions = [
// Front face
-1.0, -1.0, 1.0, 1.0, -1.0, 1.0, 1.0, 1.0, 1.0, -1.0, 1.0, 1.0,
// Back face
-1.0, -1.0, -1.0, -1.0, 1.0, -1.0, 1.0, 1.0, -1.0, 1.0, -1.0, -1.0,
// Top face
-1.0, 1.0, -1.0, -1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, -1.0,
// Bottom face
-1.0, -1.0, -1.0, 1.0, -1.0, -1.0, 1.0, -1.0, 1.0, -1.0, -1.0, 1.0,
// Right face
1.0, -1.0, -1.0, 1.0, 1.0, -1.0, 1.0, 1.0, 1.0, 1.0, -1.0, 1.0,
// Left face
-1.0, -1.0, -1.0, -1.0, -1.0, 1.0, -1.0, 1.0, 1.0, -1.0, 1.0, -1.0,
];
// Now pass the list of positions into WebGL to build the
// shape. We do this by creating a Float32Array from the
// JavaScript array, then use it to fill the current buffer.
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(positions), gl.STATIC_DRAW);
return positionBuffer;
}
function initColorBuffer(gl) {
const faceColors = [
[1.0, 1.0, 1.0, 1.0], // Front face: white
[1.0, 0.0, 0.0, 1.0], // Back face: red
[0.0, 1.0, 0.0, 1.0], // Top face: green
[0.0, 0.0, 1.0, 1.0], // Bottom face: blue
[1.0, 1.0, 0.0, 1.0], // Right face: yellow
[1.0, 0.0, 1.0, 1.0], // Left face: purple
];
// Convert the array of colors into a table for all the vertices.
var colors = [];
for (var j = 0; j < faceColors.length; ++j) {
const c = faceColors[j];
// Repeat each color four times for the four vertices of the face
colors = colors.concat(c, c, c, c);
}
const colorBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, colorBuffer);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(colors), gl.STATIC_DRAW);
return colorBuffer;
}
function initIndexBuffer(gl) {
const indexBuffer = gl.createBuffer();
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexBuffer);
// This array defines each face as two triangles, using the
// indices into the vertex array to specify each triangle's
// position.
const indices = [
0,
1,
2,
0,
2,
3, // front
4,
5,
6,
4,
6,
7, // back
8,
9,
10,
8,
10,
11, // top
12,
13,
14,
12,
14,
15, // bottom
16,
17,
18,
16,
18,
19, // right
20,
21,
22,
20,
22,
23, // left
];
// Now send the element array to GL
gl.bufferData(
gl.ELEMENT_ARRAY_BUFFER,
new Uint16Array(indices),
gl.STATIC_DRAW
);
return indexBuffer;
}
function initTextureBuffer(gl) {
const textureCoordBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, textureCoordBuffer);
const textureCoordinates = [
// Front
0.0, 0.0, 1.0, 0.0, 1.0, 1.0, 0.0, 1.0,
// Back
0.0, 0.0, 1.0, 0.0, 1.0, 1.0, 0.0, 1.0,
// Top
0.0, 0.0, 1.0, 0.0, 1.0, 1.0, 0.0, 1.0,
// Bottom
0.0, 0.0, 1.0, 0.0, 1.0, 1.0, 0.0, 1.0,
// Right
0.0, 0.0, 1.0, 0.0, 1.0, 1.0, 0.0, 1.0,
// Left
0.0, 0.0, 1.0, 0.0, 1.0, 1.0, 0.0, 1.0,
];
gl.bufferData(
gl.ARRAY_BUFFER,
new Float32Array(textureCoordinates),
gl.STATIC_DRAW
);
return textureCoordBuffer;
}
// 设置法线数据
function initNormalBuffer(gl) {
const normalBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, normalBuffer);
const vertexNormals = [
// Front
0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0,
// Back
0.0, 0.0, -1.0, 0.0, 0.0, -1.0, 0.0, 0.0, -1.0, 0.0, 0.0, -1.0,
// Top
0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0,
// Bottom
0.0, -1.0, 0.0, 0.0, -1.0, 0.0, 0.0, -1.0, 0.0, 0.0, -1.0, 0.0,
// Right
1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0,
// Left
-1.0, 0.0, 0.0, -1.0, 0.0, 0.0, -1.0, 0.0, 0.0, -1.0, 0.0, 0.0,
];
gl.bufferData(
gl.ARRAY_BUFFER,
new Float32Array(vertexNormals),
gl.STATIC_DRAW
);
return normalBuffer;
}
export { initBuffers };
javascript
// draw-scene.js
function drawScene(gl, programInfo, buffers, texture, cubeRotation) {
gl.clearColor(0.0, 0.0, 0.0, 1.0); // Clear to black, fully opaque
gl.clearDepth(1.0); // Clear everything
gl.enable(gl.DEPTH_TEST); // Enable depth testing
gl.depthFunc(gl.LEQUAL); // Near things obscure far things
// Clear the canvas before we start drawing on it.
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
// Create a perspective matrix, a special matrix that is
// used to simulate the distortion of perspective in a camera.
// Our field of view is 45 degrees, with a width/height
// ratio that matches the display size of the canvas
// and we only want to see objects between 0.1 units
// and 100 units away from the camera.
const fieldOfView = (45 * Math.PI) / 180; // in radians
const aspect = gl.canvas.clientWidth / gl.canvas.clientHeight;
const zNear = 0.1;
const zFar = 100.0;
const projectionMatrix = mat4.create();
// note: glmatrix.js always has the first argument
// as the destination to receive the result.
mat4.perspective(projectionMatrix, fieldOfView, aspect, zNear, zFar);
// Set the drawing position to the "identity" point, which is
// the center of the scene.
const modelViewMatrix = mat4.create();
// Now move the drawing position a bit to where we want to
// start drawing the square.
mat4.translate(
modelViewMatrix, // destination matrix
modelViewMatrix, // matrix to translate
[-0.0, 0.0, -6.0]
); // amount to translate
mat4.rotate(
modelViewMatrix, // destination matrix
modelViewMatrix, // matrix to rotate
cubeRotation, // amount to rotate in radians
[0, 0, 1]
); // axis to rotate around (Z)
mat4.rotate(
modelViewMatrix, // destination matrix
modelViewMatrix, // matrix to rotate
cubeRotation * 0.7, // amount to rotate in radians
[0, 1, 0]
); // axis to rotate around (Y)
mat4.rotate(
modelViewMatrix, // destination matrix
modelViewMatrix, // matrix to rotate
cubeRotation * 0.3, // amount to rotate in radians
[1, 0, 0]
); // axis to rotate around (X)
// 创建了一个新的4x4矩阵normalMatrix,用于存储法向量变换所需的数据
const normalMatrix = mat4.create();
// 计算了modelViewMatrix的逆矩阵,并将结果存储在normalMatrix中。这一步是为了得到法向量矩阵的基础,确保在对象发生变换时法向量能够正确地跟随变换
mat4.invert(normalMatrix, modelViewMatrix);
// 对normalMatrix进行转置操作。在一些情况下,转置操作可以用来修正因为缩放操作而导致的法向量失真问题。然而,在通常情况下,由于法向量矩阵通常是模型视图矩阵的逆转置,因此这一步可能并不是必要的,因为mat4.invert()函数已经正确生成了法向量矩阵
mat4.transpose(normalMatrix, normalMatrix);
// Tell WebGL how to pull out the positions from the position
// buffer into the vertexPosition attribute.
setPositionAttribute(gl, buffers, programInfo);
setTextureAttribute(gl, buffers, programInfo);
// Tell WebGL which indices to use to index the vertices
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, buffers.indices);
setNormalAttribute(gl, buffers, programInfo);
// Tell WebGL to use our program when drawing
gl.useProgram(programInfo.program);
// Set the shader uniforms
gl.uniformMatrix4fv(
programInfo.uniformLocations.projectionMatrix,
false,
projectionMatrix
);
gl.uniformMatrix4fv(
programInfo.uniformLocations.modelViewMatrix,
false,
modelViewMatrix
);
gl.uniformMatrix4fv(
programInfo.uniformLocations.normalMatrix,
false,
normalMatrix
);
// Tell WebGL we want to affect texture unit 0
gl.activeTexture(gl.TEXTURE0);
// Bind the texture to texture unit 0
gl.bindTexture(gl.TEXTURE_2D, texture);
// Tell the shader we bound the texture to texture unit 0
gl.uniform1i(programInfo.uniformLocations.uSampler, 0);
{
const vertexCount = 36;
const type = gl.UNSIGNED_SHORT;
const offset = 0;
gl.drawElements(gl.TRIANGLES, vertexCount, type, offset);
}
}
// Tell WebGL how to pull out the positions from the position
// buffer into the vertexPosition attribute.
function setPositionAttribute(gl, buffers, programInfo) {
const numComponents = 3;
const type = gl.FLOAT; // the data in the buffer is 32bit floats
const normalize = false; // don't normalize
const stride = 0; // how many bytes to get from one set of values to the next
// 0 = use type and numComponents above
const offset = 0; // how many bytes inside the buffer to start from
gl.bindBuffer(gl.ARRAY_BUFFER, buffers.position);
gl.vertexAttribPointer(
programInfo.attribLocations.vertexPosition,
numComponents,
type,
normalize,
stride,
offset
);
gl.enableVertexAttribArray(programInfo.attribLocations.vertexPosition);
}
// Tell WebGL how to pull out the colors from the color buffer
// into the vertexColor attribute.
function setColorAttribute(gl, buffers, programInfo) {
const numComponents = 4;
const type = gl.FLOAT;
const normalize = false;
const stride = 0;
const offset = 0;
gl.bindBuffer(gl.ARRAY_BUFFER, buffers.color);
gl.vertexAttribPointer(
programInfo.attribLocations.vertexColor,
numComponents,
type,
normalize,
stride,
offset
);
gl.enableVertexAttribArray(programInfo.attribLocations.vertexColor);
}
// tell webgl how to pull out the texture coordinates from buffer
function setTextureAttribute(gl, buffers, programInfo) {
const num = 2; // every coordinate composed of 2 values
const type = gl.FLOAT; // the data in the buffer is 32-bit float
const normalize = false; // don't normalize
const stride = 0; // how many bytes to get from one set to the next
const offset = 0; // how many bytes inside the buffer to start from
gl.bindBuffer(gl.ARRAY_BUFFER, buffers.textureCoord);
gl.vertexAttribPointer(
programInfo.attribLocations.textureCoord,
num,
type,
normalize,
stride,
offset
);
gl.enableVertexAttribArray(programInfo.attribLocations.textureCoord);
}
// 设置法线数据读取步长并将法线数据传递给着色器
function setNormalAttribute(gl, buffers, programInfo) {
const numComponents = 3;
const type = gl.FLOAT;
const normalize = false;
const stride = 0;
const offset = 0;
gl.bindBuffer(gl.ARRAY_BUFFER, buffers.normal);
gl.vertexAttribPointer(
programInfo.attribLocations.vertexNormal,
numComponents,
type,
normalize,
stride,
offset
);
gl.enableVertexAttribArray(programInfo.attribLocations.vertexNormal);
}
export { drawScene };