第 7 节:光照模型
🎯 学习目标
- 理解 Lambert 漫反射光照
- 理解 Phong 镜面高光
- 实现完整的光照模型
- 对比原生 WebGL 和 Three.js 的光照系统
📖 理论:光照模型
Lambert 漫反射
光线在粗糙表面均匀反射,亮度取决于光线与法线的夹角:
float NdotL = max(dot(normal, lightDir), 0.0);
vec3 diffuse = lightColor * NdotL;
Phong 镜面高光
光线在光滑表面产生高光,取决于视线与反射光线的夹角:
vec3 reflectDir = reflect(-lightDir, normal);
float spec = pow(max(dot(viewDir, reflectDir), 0.0), shininess);
vec3 specular = lightColor * spec;
Blinn-Phong 改进
使用半角向量代替反射向量,计算更快:
vec3 halfDir = normalize(lightDir + viewDir);
float spec = pow(max(dot(normal, halfDir), 0.0), shininess);
完整光照模型
vec3 ambient = ambientColor * ambientStrength;
vec3 diffuse = lightColor * max(dot(normal, lightDir), 0.0);
vec3 specular = lightColor * pow(max(dot(normal, halfDir), 0.0), shininess);
vec3 result = ambient + diffuse + specular;
💻 原生 WebGL 实现
实现 Blinn-Phong 光照模型:
<!DOCTYPE html>
<html>
<head>
<title>光照模型 - 原生 WebGL</title>
<style>
body {
margin: 0;
}
canvas {
display: block;
}
</style>
</head>
<body>
<canvas id="glcanvas"></canvas>
<script src="https://cdn.jsdelivr.net/npm/gl-matrix@3.4.3/gl-matrix-min.js"></script>
<script>
const canvas = document.getElementById("glcanvas");
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
const gl = canvas.getContext("webgl");
if (!gl) {
alert("无法初始化 WebGL");
}
const mat4 = glMatrix.mat4;
const vec3 = glMatrix.vec3;
// Shader 源码
const vertexShaderSource = `
attribute vec4 aVertexPosition;
attribute vec3 aNormal;
uniform mat4 uModelMatrix;
uniform mat4 uViewMatrix;
uniform mat4 uProjectionMatrix;
uniform mat3 uNormalMatrix;
varying vec3 vNormal;
varying vec3 vPosition;
void main() {
vec4 worldPos = uModelMatrix * aVertexPosition;
vPosition = worldPos.xyz;
vNormal = normalize(uNormalMatrix * aNormal);
gl_Position = uProjectionMatrix * uViewMatrix * worldPos;
}
`;
const fragmentShaderSource = `
precision mediump float;
varying vec3 vNormal;
varying vec3 vPosition;
uniform vec3 uLightPosition;
uniform vec3 uLightColor;
uniform vec3 uViewPosition;
uniform vec3 uAmbientColor;
uniform float uShininess;
void main() {
// 环境光
vec3 ambient = uAmbientColor * 0.2;
// 漫反射
vec3 lightDir = normalize(uLightPosition - vPosition);
float diff = max(dot(vNormal, lightDir), 0.0);
vec3 diffuse = uLightColor * diff;
// 镜面高光(Blinn-Phong)
vec3 viewDir = normalize(uViewPosition - vPosition);
vec3 halfDir = normalize(lightDir + viewDir);
float spec = pow(max(dot(vNormal, halfDir), 0.0), uShininess);
vec3 specular = uLightColor * spec;
vec3 result = ambient + diffuse + specular;
gl_FragColor = vec4(result, 1.0);
}
`;
// 编译函数(复用之前的)
function createShader(gl, type, source) {
const shader = gl.createShader(type);
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
console.error("编译错误:", gl.getShaderInfoLog(shader));
gl.deleteShader(shader);
return null;
}
return shader;
}
function createProgram(gl, vertexShader, fragmentShader) {
const program = gl.createProgram();
gl.attachShader(program, vertexShader);
gl.attachShader(program, fragmentShader);
gl.linkProgram(program);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
console.error("链接错误:", gl.getProgramInfoLog(program));
gl.deleteProgram(program);
return null;
}
return program;
}
const vertexShader = createShader(gl, gl.VERTEX_SHADER, vertexShaderSource);
const fragmentShader = createShader(gl, gl.FRAGMENT_SHADER, fragmentShaderSource);
const program = createProgram(gl, vertexShader, fragmentShader);
gl.useProgram(program);
// 球体顶点数据(简化版,使用立方体代替)
// 实际项目中应该使用 SphereGeometry 或手动生成球体顶点
const geometry = {
positions: [
-1, -1, 1, 1, -1, 1, 1, 1, 1, -1, 1, 1, // 前面
-1, -1, -1, -1, 1, -1, 1, 1, -1, 1, -1, -1, // 后面
-1, 1, -1, -1, 1, 1, 1, 1, 1, 1, 1, -1, // 上面
-1, -1, -1, 1, -1, -1, 1, -1, 1, -1, -1, 1, // 下面
1, -1, -1, 1, 1, -1, 1, 1, 1, 1, -1, 1, // 右面
-1, -1, -1, -1, -1, 1, -1, 1, 1, -1, 1, -1, // 左面
],
normals: [
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, // 左面
],
indices: [
0, 1, 2, 0, 2, 3, 4, 5, 6, 4, 6, 7, 8, 9, 10, 8, 10, 11, 12, 13, 14, 12, 14, 15, 16, 17, 18, 16, 18, 19, 20, 21, 22, 20, 22, 23,
],
};
// 创建缓冲区
const positionBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(geometry.positions), gl.STATIC_DRAW);
const normalBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, normalBuffer);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(geometry.normals), gl.STATIC_DRAW);
const indexBuffer = gl.createBuffer();
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexBuffer);
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Uint16Array(geometry.indices), gl.STATIC_DRAW);
// 配置属性
const positionLoc = gl.getAttribLocation(program, "aVertexPosition");
const normalLoc = gl.getAttribLocation(program, "aNormal");
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
gl.enableVertexAttribArray(positionLoc);
gl.vertexAttribPointer(positionLoc, 3, gl.FLOAT, false, 0, 0);
gl.bindBuffer(gl.ARRAY_BUFFER, normalBuffer);
gl.enableVertexAttribArray(normalLoc);
gl.vertexAttribPointer(normalLoc, 3, gl.FLOAT, false, 0, 0);
// 获取 Uniform 位置
const modelLoc = gl.getUniformLocation(program, "uModelMatrix");
const viewLoc = gl.getUniformLocation(program, "uViewMatrix");
const projLoc = gl.getUniformLocation(program, "uProjectionMatrix");
const normalMatLoc = gl.getUniformLocation(program, "uNormalMatrix");
const lightPosLoc = gl.getUniformLocation(program, "uLightPosition");
const lightColorLoc = gl.getUniformLocation(program, "uLightColor");
const viewPosLoc = gl.getUniformLocation(program, "uViewPosition");
const ambientLoc = gl.getUniformLocation(program, "uAmbientColor");
const shininessLoc = gl.getUniformLocation(program, "uShininess");
// 创建矩阵
const modelMatrix = mat4.create();
const viewMatrix = mat4.create();
const projectionMatrix = mat4.create();
const normalMatrix = mat4.create();
mat4.perspective(projectionMatrix, Math.PI / 4, canvas.width / canvas.height, 0.1, 100.0);
mat4.lookAt(viewMatrix, [0, 0, 5], [0, 0, 0], [0, 1, 0]);
gl.enable(gl.DEPTH_TEST);
// 渲染循环
let angle = 0;
function animate() {
requestAnimationFrame(animate);
angle += 0.02;
mat4.identity(modelMatrix);
mat4.rotateY(modelMatrix, modelMatrix, angle);
// 计算法线矩阵(模型矩阵的逆矩阵的转置)
mat4.invert(normalMatrix, modelMatrix);
mat4.transpose(normalMatrix, normalMatrix);
// 设置 Uniform
gl.uniformMatrix4fv(modelLoc, false, modelMatrix);
gl.uniformMatrix4fv(viewLoc, false, viewMatrix);
gl.uniformMatrix4fv(projLoc, false, projectionMatrix);
gl.uniformMatrix3fv(normalMatLoc, false, [
normalMatrix[0],
normalMatrix[1],
normalMatrix[2],
normalMatrix[4],
normalMatrix[5],
normalMatrix[6],
normalMatrix[8],
normalMatrix[9],
normalMatrix[10],
]);
gl.uniform3f(lightPosLoc, 2, 2, 2);
gl.uniform3f(lightColorLoc, 1, 1, 1);
gl.uniform3f(viewPosLoc, 0, 0, 5);
gl.uniform3f(ambientLoc, 1, 1, 1);
gl.uniform1f(shininessLoc, 32.0);
gl.viewport(0, 0, canvas.width, canvas.height);
gl.clearColor(0.1, 0.1, 0.1, 1.0);
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexBuffer);
gl.drawElements(gl.TRIANGLES, geometry.indices.length, gl.UNSIGNED_SHORT, 0);
}
animate();
</script>
</body>
</html>
关键点
- 法线矩阵:需要将法线从模型空间变换到世界空间
- 光照计算:在片段着色器中计算,获得平滑效果
- 向量归一化:所有方向向量必须归一化
- Blinn-Phong:使用半角向量提高性能
🎨 Three.js 对比实现
Three.js 提供了多种内置光照材质:
<!DOCTYPE html>
<html>
<head>
<title>光照模型 - Three.js</title>
<style>
body {
margin: 0;
}
canvas {
display: block;
}
</style>
</head>
<body>
<canvas id="canvas"></canvas>
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js"
}
}
</script>
<script type="module">
import * as THREE from "three";
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x111111);
const camera = new THREE.PerspectiveCamera(
75,
window.innerWidth / window.innerHeight,
0.1,
1000
);
camera.position.z = 5;
const renderer = new THREE.WebGLRenderer({
canvas: document.getElementById("canvas"),
});
renderer.setSize(window.innerWidth, window.innerHeight);
// 创建几何体
const geometry = new THREE.SphereGeometry(1, 32, 32);
// 使用 MeshStandardMaterial(PBR 材质)
const material = new THREE.MeshStandardMaterial({
color: 0xffffff,
metalness: 0.5,
roughness: 0.5,
});
const mesh = new THREE.Mesh(geometry, material);
scene.add(mesh);
// 添加灯光
const ambientLight = new THREE.AmbientLight(0xffffff, 0.5);
scene.add(ambientLight);
const directionalLight = new THREE.DirectionalLight(0xffffff, 1);
directionalLight.position.set(2, 2, 2);
scene.add(directionalLight);
// 渲染循环
function animate() {
requestAnimationFrame(animate);
mesh.rotation.y += 0.02;
renderer.render(scene, camera);
}
animate();
</script>
</body>
</html>
Three.js 的光照材质
- MeshBasicMaterial:不受光照影响
- MeshLambertMaterial:Lambert 漫反射
- MeshPhongMaterial:Phong 光照模型
- MeshStandardMaterial:PBR 材质(物理渲染)
🔍 原理对比分析
1. 光照计算
原生 WebGL:
- 需要手动实现所有光照计算
- 需要手动传递所有参数
- 完全控制光照效果
Three.js:
- 内置多种光照模型
- 自动处理参数传递
- 使用方便,但灵活性有限
2. 法线处理
原生 WebGL:
// 需要手动计算法线矩阵
mat4.invert(normalMatrix, modelMatrix);
mat4.transpose(normalMatrix, normalMatrix);
Three.js:
// 自动处理法线变换
// BufferGeometry 自动计算法线
3. 材质系统
原生 WebGL:
- 需要手动编写 Shader
- 需要手动管理 Uniform
Three.js:
- 内置多种材质
- 自动生成 Shader
- 支持自定义 Shader
📝 编程作业
基础作业(⭐)
任务:实现 Lambert 漫反射光照
要求:
- 创建一个球体
- 实现基础的 Lambert 光照
- 添加一个可移动的光源
检查清单:
- 球体有正确的光照效果
- 理解法线的作用
- 理解点积的计算
进阶作业(⭐⭐)
任务:实现完整的 Blinn-Phong 光照
要求:
- 添加环境光
- 添加漫反射
- 添加镜面高光
- 实现可调节的光源位置和强度
检查清单:
- 三种光照分量都正确
- 高光效果明显
- 可以调节参数
挑战作业(⭐⭐⭐)
任务:实现 PBR 材质
要求:
- 实现基于物理的渲染
- 支持金属度和粗糙度
- 支持环境贴图
扩展功能:
- 支持多光源
- 支持阴影
- 支持法线贴图
🎓 本节总结
关键概念
- Lambert 漫反射:粗糙表面的均匀反射
- Phong 镜面高光:光滑表面的高光
- Blinn-Phong:使用半角向量的改进模型
- 法线矩阵:将法线从模型空间变换到世界空间
原生 WebGL vs Three.js
| 方面 | 原生 WebGL | Three.js |
|---|---|---|
| 光照计算 | 手动实现 | 内置材质 |
| 法线处理 | 手动计算 | 自动处理 |
| 灵活性 | 完全控制 | 受限于 API |
| 代码量 | 多 | 少 |
最佳实践
- 使用 Blinn-Phong:比 Phong 性能更好
- 归一化向量:所有方向向量必须归一化
- 在片段着色器计算:获得平滑的光照效果
- 使用 PBR:Three.js 中优先使用 MeshStandardMaterial
下一步
完成作业后,进入下一节:第 8 节:光照模型