欢迎回到我们的 3D 之旅。在上一篇文章中,我们成功地构建并旋转了一个 3D 立方体。这非常酷,但你有没有觉得它看起来……有点奇怪?无论它怎么旋转,每个面的颜色都一成不变,像一个悬浮在空中的彩色纸盒,完全没有体积感。
问题出在哪里?没有光。
在现实世界中,我们能感知到物体的形状和体积,是因为光线照射在它们表面,产生了明暗变化。一个球体,正对着光的一面最亮,侧面逐渐变暗,背面则处于阴影中。正是这种明暗的渐变,告诉我们的大脑“这是一个球体,而不是一个平面圆盘”。
今天,我们的目标就是在 WebGL 中模拟这个过程,为我们的场景引入最基础、也最重要的光照模型。
光照的秘密武器:法向量 (Normal Vector)
要计算光照,我们必须知道每个面的朝向。一个面是正对着光源,还是侧对着?这个“朝向”信息,在计算机图形学中,由一个叫做法向量的东西来表示。
法向量是一个垂直于表面并指向外部的向量(一个带箭头的线)。想象一下,在你立方体的每个面的正中心,都长出了一根垂直的刺,这根刺的方向,就是这个面的法向量。
有了法向量,光照计算的逻辑就变得非常直观:
- 如果一个面的法向量与光线方向的夹角很小(几乎正对着光),这个面就很亮。
- 如果夹角是 90 度(光线擦着表面过去),这个面就几乎没有光。
- 如果夹角大于 90 度(面背对着光),这个面就处于阴影中。
这个夹角关系,我们可以用一个非常高效的数学工具来计算——点积 (Dot Product)。两个向量的点积结果,直接反映了它们方向的相似程度。
最基础的光照:环境光与漫反射
一个完整的光照模型很复杂,但我们可以从两个最简单的部分开始:
-
环境光 (Ambient Light):
- 想象一个没有太阳的阴天,光线经过无数次反射,从四面八方均匀地散射过来。物体没有明显的阴影,但你依然能看清它们。这就是环境光。
- 在 WebGL 中,它是一个“作弊”的技巧:我们给整个场景一个基础的、微弱的亮度,确保物体背光的一面不是纯黑一片,能保留一些细节。
-
漫反射光 (Diffuse Light):
- 这是我们刚才讨论的主要光照。它模拟的是光线从某个特定方向(比如太阳)照射过来,在粗糙表面(如纸、墙壁)上向各个方向均匀反射的效果。
- 一个表面的漫反射亮度,就取决于它的法向量和光线方向的点积。
我们将把这两种光结合起来:最终颜色 = 物体基色 * (环境光颜色 + 漫反射光颜色)
着色器大升级
1. 顶点着色器:处理法向量
- 它需要接收一个新的
attribute:a_normal。 - 一个陷阱: 当我们旋转或缩放模型时,法向量也必须跟着旋转。但我们不能直接用 MVP 矩阵去乘法向量,尤其在有非等比缩放时,会导致法向量不再垂直于表面。我们需要一个专门的法线矩阵 (Normal Matrix),它通常是模型矩阵(或模型-视图矩阵)的逆转置矩阵。听起来很复杂,但幸运的是,
gl-matrix这样的库能帮我们一键生成。 - 最后,它将变换后的法向量,通过
varying传递给片元着色器。
attribute vec4 a_position;
attribute vec3 a_normal; // 新增: 顶点法向量
attribute vec4 a_color;
uniform mat4 u_mvpMatrix;
uniform mat4 u_modelMatrix; // 新增: 模型矩阵,用于光照计算
uniform mat4 u_normalMatrix; // 新增: 法线矩阵
varying vec4 v_color;
varying vec3 v_normal; // 新增: 传递变换后的法向量
varying vec3 v_surfaceToWorld; // 新增: 传递顶点在世界空间中的位置
void main() {
gl_Position = u_mvpMatrix * a_position;
// 变换法向量并传递
v_normal = (u_normalMatrix * vec4(a_normal, 0.0)).xyz;
// 变换顶点位置到世界空间并传递
v_surfaceToWorld = (u_modelMatrix * a_position).xyz;
v_color = a_color;
}
(注:更精确的光照计算应在世界空间中进行,所以我们多传递一个 v_surfaceToWorld 变量)
2. 片元着色器:执行光照计算
这是所有魔法发生的地方。它接收法向量、世界坐标,以及代表光源信息的 uniform 变量,然后执行点积运算。
precision mediump float;
varying vec4 v_color;
varying vec3 v_normal;
varying vec3 v_surfaceToWorld;
uniform vec3 u_lightDirection; // 新增: 光源方向
uniform vec3 u_lightColor; // 新增: 光源颜色
uniform vec3 u_ambientLight; // 新增: 环境光
void main() {
// 归一化法向量 (插值过程可能使其长度不为1)
vec3 normal = normalize(v_normal);
// 计算光照强度 (点积)
// 我们取反光线方向,因为我们关心的是从表面到光源的方向
float light_intensity = max(dot(normal, -u_lightDirection), 0.0);
// 计算漫反射颜色
vec3 diffuse = u_lightColor * light_intensity;
// 最终颜色 = 物体基色 * (环境光 + 漫反射光)
// v_color.rgb 是物体的原始颜色
vec3 final_color = v_color.rgb * (u_ambientLight + diffuse);
gl_FragColor = vec4(final_color, v_color.a);
}
3. JavaScript:提供所有数据 JavaScript 现在需要准备更多的数据:
- 顶点数据: 缓冲中需要包含每个顶点的法向量信息
[X, Y, Z, NX, NY, NZ, ...]。 - 新的 Uniforms: 需要获取光照相关 uniform 的位置,并在每一帧设置它们的值(光源方向、颜色等)。
- 法线矩阵计算: 在动画循环中,根据模型矩阵计算出法线矩阵,并上传。
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>WebGL 教程 7:基础光照</title>
<style>
body { background-color: #222; color: #eee; text-align: center; }
canvas { background-color: #000; border: 1px solid #555; }
</style>
</head>
<body onload="main()">
<h1>照亮你的世界 - WebGL 基础光照</h1>
<canvas id="webgl-canvas" width="600" height="600"></canvas>
<!-- 顶点着色器 -->
<script id="vertex-shader" type="x-shader/x-vertex">
attribute vec4 a_position;
attribute vec3 a_normal; // 新: 法向量
uniform mat4 u_mvpMatrix;
uniform mat4 u_normalMatrix; // 新: 法线矩阵
varying vec3 v_normal;
void main() {
gl_Position = u_mvpMatrix * a_position;
// 使用法线矩阵变换法向量
v_normal = (u_normalMatrix * vec4(a_normal, 0.0)).xyz;
}
</script>
<!-- 片元着色器 -->
<script id="fragment-shader" type="x-shader/x-fragment">
precision mediump float;
varying vec3 v_normal;
uniform vec3 u_lightDirection; // 新: 光源方向
uniform vec3 u_lightColor; // 新: 光源颜色
uniform vec3 u_ambientLight; // 新: 环境光颜色
void main() {
// 对插值后的法向量进行归一化
vec3 normal = normalize(v_normal);
// 计算光线方向和法向量的点积
// 我们希望光线方向是从物体表面指向光源
// 我们用 max(..., 0.0) 来防止点积为负值(即光从背面照射)
float light_factor = max(dot(normal, -normalize(u_lightDirection)), 0.0);
// 计算漫反射颜色
vec3 diffuse = u_lightColor * light_factor;
// 最终的颜色是环境光和漫反射光的总和
// 这里我们假设物体基色为白色(1,1,1),直接用光色
vec3 finalColor = u_ambientLight + diffuse;
gl_FragColor = vec4(finalColor, 1.0);
}
</script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/gl-matrix/2.8.1/gl-matrix-min.js"></script>
<script>
function main() {
const canvas = document.getElementById('webgl-canvas');
const gl = canvas.getContext('webgl');
if (!gl) { alert('WebGL not supported!'); return; }
const program = createProgram(gl,
document.getElementById('vertex-shader').text,
document.getElementById('fragment-shader').text);
const locations = {
position: gl.getAttribLocation(program, "a_position"),
normal: gl.getAttribLocation(program, "a_normal"),
mvpMatrix: gl.getUniformLocation(program, "u_mvpMatrix"),
normalMatrix: gl.getUniformLocation(program, "u_normalMatrix"),
lightDirection: gl.getUniformLocation(program, "u_lightDirection"),
lightColor: gl.getUniformLocation(program, 'u_lightColor'),
ambientLight: gl.getUniformLocation(program, 'u_ambientLight'),
};
const buffer = initBuffers(gl);
gl.useProgram(program);
gl.enable(gl.DEPTH_Test);
gl.depthFunc(gl.LEQUAL);
let cubeRotation = 0.0;
let lastTime = 0;
function animate(now) {
now *= 0.001;
const deltaTime = now - lastTime;
lastTime = now;
drawScene(gl, locations, buffer, cubeRotation);
cubeRotation += deltaTime;
requestAnimationFrame(animate);
}
requestAnimationFrame(animate);
}
function drawScene(gl, locations, buffers, rotation) {
gl.viewport(0, 0, gl.canvas.width, gl.canvas.height);
gl.clearColor(0.1, 0.1, 0.1, 1.0);
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
// --- 矩阵设置 ---
const projectionMatrix = mat4.create();
mat4.perspective(projectionMatrix, 45 * Math.PI / 180, gl.canvas.clientWidth / gl.canvas.clientHeight, 0.1, 100.0);
const viewMatrix = mat4.create();
mat4.lookAt(viewMatrix,,,);
const modelMatrix = mat4.create();
mat4.rotate(modelMatrix, modelMatrix, rotation * 0.5,);
mat4.rotate(modelMatrix, modelMatrix, rotation * 0.3,);
const mvpMatrix = mat4.create();
mat4.multiply(mvpMatrix, projectionMatrix, viewMatrix);
mat4.multiply(mvpMatrix, mvpMatrix, modelMatrix);
// --- 法线矩阵计算 ---
const normalMatrix = mat4.create();
mat4.invert(normalMatrix, modelMatrix);
mat4.transpose(normalMatrix, normalMatrix);
// --- 数据绑定与 Uniform 设置 ---
gl.bindBuffer(gl.ARRAY_BUFFER, buffers.position);
gl.vertexAttribPointer(locations.position, 3, gl.FLOAT, false, 0, 0);
gl.enableVertexAttribArray(locations.position);
gl.bindBuffer(gl.ARRAY_BUFFER, buffers.normal);
gl.vertexAttribPointer(locations.normal, 3, gl.FLOAT, false, 0, 0);
gl.enableVertexAttribArray(locations.normal);
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, buffers.indices);
gl.uniformMatrix4fv(locations.mvpMatrix, false, mvpMatrix);
gl.uniformMatrix4fv(locations.normalMatrix, false, normalMatrix);
// 设置光照 Uniforms
gl.uniform3fv(locations.lightDirection, [0.5, 0.7, 1.0]);
gl.uniform3fv(locations.lightColor, [1.0, 1.0, 1.0]); // 白色光
gl.uniform3fv(locations.ambientLight, [0.2, 0.2, 0.2]); // 灰色环境光
// --- 绘制 ---
gl.drawElements(gl.TRIANGLES, 36, gl.UNSIGNED_SHORT, 0);
}
function initBuffers(gl) {
const positions = new Float32Array([ /* ...立方体顶点... */ ]);
const normals = new Float32Array([ /* ...立方体法线... */ ]);
const indices = new Uint16Array([ /* ...立方体索引... */ ]);
// Data ommited for brevity, it's a standard cube definition
const faceColors = [,,,,, ];
var colors = [];
for (var j=0; j<faceColors.length; ++j) { const c = faceColors[j]; 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);
const p = [ -1,1,1, -1,-1,1, 1,-1,1, 1,1,1, -1,1,-1, -1,-1,-1, 1,-1,-1, 1,1,-1 ]; const pos = new Float32Array(p);
const ind = new Uint16Array([ 0,1,2, 0,2,3, 3,2,6, 3,6,7, 7,6,5, 7,5,4, 4,5,1, 4,1,0, 4,0,3, 4,3,7, 1,5,6, 1,6,2 ]);
const norm = new Float32Array([ 0,0,1,0,0,1,0,0,1,0,0,1, 0,0,-1,0,0,-1,0,0,-1,0,0,-1, 0,1,0,0,1,0,0,1,0,0,1,0, 0,-1,0,0,-1,0,0,-1,0,0,-1,0, 1,0,0,1,0,0,1,0,0,1,0,0, -1,0,0,-1,0,0,-1,0,0,-1,0,0 ]);
const positionBuffer = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer); gl.bufferData(gl.ARRAY_BUFFER, pos, gl.STATIC_DRAW);
const normalBuffer = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, normalBuffer); gl.bufferData(gl.ARRAY_BUFFER, norm, gl.STATIC_DRAW);
const indexBuffer = gl.createBuffer(); gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexBuffer); gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, ind, gl.STATIC_DRAW);
return { position: positionBuffer, normal: normalBuffer, indices: indexBuffer, };
}
function createProgram(gl, vsSource, fsSource) { /* ... same as before ... */ function createShader(type, source) { const shader = gl.createShader(type); gl.shaderSource(shader, source); gl.compileShader(shader); if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { console.error("Shader error:", gl.getShaderInfoLog(shader)); gl.deleteShader(shader); return null; } return shader; } const vs = createShader(gl.VERTEX_SHADER, vsSource); const fs = createShader(gl.FRAGMENT_SHADER, fsSource); const prog = gl.createProgram(); gl.attachShader(prog, vs); gl.attachShader(prog, fs); gl.linkProgram(prog); if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) { console.error("Program link error:", gl.getProgramInfoLog(prog)); gl.deleteProgram(prog); return null; } return prog; }
</script>
</body>
</html>
总结与展望
运行代码,见证奇迹的时刻!你的立方体现在看起来应该有了真实的体积感。当它旋转时,不同的面会根据它们与虚拟光源的角度而变亮或变暗。我们成功地为这个冰冷的数字世界注入了第一缕光。
今天,我们掌握了 3D 渲染中至关重要的一环:
- 理解了法向量在光照计算中的核心地位。
- 实现了环境光 + 漫反射的基础光照模型。
- 学会了使用法线矩阵来正确变换法向量。
- 在着色器中,通过点积运算,将数学和物理结合了起来,创造了视觉上的真实感。
但光的世界远不止于此。你有没有想过,为什么金属和塑料的反光方式完全不同?金属上会有刺眼的高光,而我们现在的立方体表面却非常粗糙。
在下一篇教程中,我们将为光照模型加入最后一块重要拼图:镜面反射高光 (Specular Highlights)。我们将模拟物体表面“高光”的形成,让我们的材质拥有“光泽感”,从而能够区分出磨砂表面和抛光表面。