WebGL 中的光照模型有哪些?如何实现 Phong 光照模型?
WebGL 光照模型有哪些?
WebGL 中常用的光照模型分为两类:局部光照模型和全局光照模型。面试中重点考察的是局部光照模型,核心有三种:
| 光照模型 | 特点 | 典型用途 |
|---|---|---|
| Lambert(漫反射) | 仅计算漫反射,无高光 | 粗糙表面如墙壁、布料 |
| Phong | 环境光 + 漫反射 + 镜面反射 | 通用物体渲染 |
| Blinn-Phong | Phong 改进版,用半角向量替代反射向量 | 实际项目首选 |
此外还有基于物理的 PBR(Physically Based Rendering)模型,它用粗糙度、金属度等物理参数替代 Phong 的经验参数,渲染结果更真实,是现代引擎的主流方案。
Phong 光照模型的三个分量
Phong 模型的核心公式:最终颜色 = 环境光 + 漫反射 + 镜面反射
环境光(Ambient)
模拟间接光照,不依赖光源位置,给场景一个基础亮度:
glslvec3 ambient = ambientStrength * lightColor;
漫反射(Diffuse)
遵循朗伯余弦定律:表面接收的光量与光线和法线的夹角余弦成正比:
glslfloat diff = max(dot(normal, lightDir), 0.0); vec3 diffuse = diff * lightColor;
当光线与法线垂直时 dot 为 0,背面为负数,所以用 max(..., 0.0) 截断。
镜面反射(Specular)
模拟光滑表面的高光,取决于观察方向与反射方向的接近程度:
glsl// Phong 模型:用反射向量 vec3 reflectDir = reflect(-lightDir, normal); float spec = pow(max(dot(viewDir, reflectDir), 0.0), shininess); // Blinn-Phong 模型:用半角向量(更高效) vec3 halfwayDir = normalize(lightDir + viewDir); float spec = pow(max(dot(normal, halfwayDir), 0.0), shininess);
shininess 越大高光越集中,一般取 32、64、128 等 2 的幂次。
Phong 与 Blinn-Phong 的区别
这是面试高频追问点:
- Phong 用反射向量 R,计算
dot(V, R),高光在掠射角时会出现截断瑕疵 - Blinn-Phong 用半角向量 H = normalize(L + V),计算
dot(N, H),避免了反射向量计算(省去reflect),高光过渡更自然 - 性能上 Blinn-Phong 更优,视觉质量也更好,实际项目中几乎都用 Blinn-Phong
完整的 Phong 着色器实现
顶点着色器
glslattribute vec3 a_position; attribute vec3 a_normal; uniform mat4 u_modelMatrix; uniform mat4 u_viewMatrix; uniform mat4 u_projectionMatrix; uniform mat3 u_normalMatrix; uniform vec3 u_lightPosition; uniform vec3 u_cameraPosition; varying vec3 v_normal; varying vec3 v_fragPos; void main() { vec4 worldPos = u_modelMatrix * vec4(a_position, 1.0); gl_Position = u_projectionMatrix * u_viewMatrix * worldPos; // 法线变换必须用 normalMatrix,不能用 modelMatrix v_normal = normalize(u_normalMatrix * a_normal); v_fragPos = worldPos.xyz; }
片段着色器
glslprecision mediump float; varying vec3 v_normal; varying vec3 v_fragPos; uniform vec3 u_lightPosition; uniform vec3 u_cameraPosition; uniform vec3 u_lightColor; uniform float u_ambientStrength; uniform float u_specularStrength; uniform float u_shininess; void main() { vec3 normal = normalize(v_normal); vec3 lightDir = normalize(u_lightPosition - v_fragPos); vec3 viewDir = normalize(u_cameraPosition - v_fragPos); // 环境光 vec3 ambient = u_ambientStrength * u_lightColor; // 漫反射 float diff = max(dot(normal, lightDir), 0.0); vec3 diffuse = diff * u_lightColor; // 镜面反射(Blinn-Phong) vec3 halfwayDir = normalize(lightDir + viewDir); float spec = pow(max(dot(normal, halfwayDir), 0.0), u_shininess); vec3 specular = u_specularStrength * spec * u_lightColor; vec3 result = (ambient + diffuse + specular); gl_FragColor = vec4(result, 1.0); }
为什么法线变换要用 normalMatrix 而不是 modelMatrix?
这是面试中容易被追问的关键点。直接用 modelMatrix 变换法线,在非均匀缩放下会导致法线不再垂直于表面。正确做法是使用模型矩阵的逆转置矩阵的左上 3x3 部分:
javascriptconst normalMatrix = mat3.create(); mat3.fromMat4(normalMatrix, modelMatrix); mat3.invert(normalMatrix, normalMatrix); mat3.transpose(normalMatrix, normalMatrix);
数学原理:法线是表面的梯度方向,梯度变换的矩阵是坐标变换矩阵的逆转置。只有当 modelMatrix 只有旋转和均匀缩放时,normalMatrix 才等于 modelMatrix 的 3x3 部分。
多光源与衰减
实际场景中往往需要多个光源,并处理光照随距离的衰减:
glsl// 点光源衰减公式 float distance = length(lightPosition - fragPos); float attenuation = 1.0 / (1.0 + 0.09 * distance + 0.032 * distance * distance);
多个光源时,环境光只算一次,漫反射和镜面反射逐光源累加后再乘以衰减值。移动端建议不超过 2 个动态光源,桌面端 4-8 个。光源更多时应考虑延迟渲染(Deferred Shading),将几何处理与光照计算分离。
Gouraud 着色与 Phong 着色
| 对比项 | Gouraud | Phong |
|---|---|---|
| 计算位置 | 顶点着色器 | 片段着色器 |
| 效果 | 插值导致高光不准确 | 逐像素计算,高光精确 |
| 性能 | 更快 | 较慢 |
| 适用场景 | 低端设备或顶点密度高的模型 | 追求渲染质量 |
Gouraud 着色在顶点处计算光照然后插值,当三角形覆盖范围大时会丢失高光细节。Phong 着色逐片段计算,是现代 WebGL 的主流选择。
常见面试追问
Q:Phong 模型和 PBR 的本质区别是什么? Phong 是经验模型,参数(环境光强度、高光强度、shininess)没有物理意义,调参靠感觉。PBR 基于微表面理论和能量守恒,参数(粗糙度、金属度)有明确的物理含义,同一组参数在不同光照环境下结果一致。
Q:为什么 Blinn-Phong 在掠射角效果更好?
Phong 的反射向量 R 在掠射角时计算出的高光会出现不对称的截断,因为 dot(V, R) 在某些角度会突然变为 0。Blinn-Phong 的半角向量 H 总是在 L 和 V 之间,dot(N, H) 的过渡更平滑自然。
Q:法线不归一化会导致什么问题?
法线长度不等于 1 时,dot(N, L) 的结果会偏大或偏小,漫反射亮度出错;插值后的法线尤其容易变短(两个单位法线的插值不一定是单位向量),所以片段着色器中必须重新 normalize。