WebGPU 方向光照明(Directional Lighting)
原文作者: webgpufundamentals Contributors
原文链接: https://webgpufundamentals.org/webgpu/lessons/webgpu-lighting-directional.html
本文假设你已经阅读了摄像机相关文章。实现光照的方式有很多,其中最简单的一种就是方向光(directional lighting)。方向光假设光线从某个方向均匀地照射过来,就像晴天时的太阳——由于距离极远,所有光线可以视为平行射向物体表面。
计算方向光其实非常简单:如果我们知道光线传播的方向,以及物体表面朝向,那么对这两个方向做点积(dot product),就能得到两个方向夹角的余弦值。点积结果为 1 表示光线直射表面,为 -1 表示完全背向。
法线(Normals)
在 3D 图形学中,法线是描述表面朝向的单位向量。下面是立方体和球体的法线示意图(线条表示每个顶点的法线,颜色表示方向:+X 为红,+Y 为绿,+Z 为蓝)。
立方体的每个角有三条法线,因为每个面朝向不同,需要三条法线来表示。
现在,我们把法线添加到之前的 F 模型上,替换掉原来的顶点颜色。由于 F 模型非常方正,面都对齐到 X、Y、Z 轴,因此法线很容易确定:朝前是 0,0,1,朝后是 0,0,-1,朝左是 -1,0,0,朝右是 1,0,0,向上是 0,1,0,向下是 0,-1,0。
下面是 createFVertices 的核心修改(省略了部分重复代码):
function createFVertices() {
const positions = [
// ... 顶点位置数据 ...
];
const indices = [
// ... 索引数据 ...
];
// 每个四边形的法线(6 个面 × 6 个四边形 = 16 个四边形,每个四边形 3 个顶点)
const normals = [
0, 0, 1, // front face
0, 0, 1,
0, 0, 1,
0, 0, -1, // back face
0, 0, -1,
0, 0, -1,
0, 1, 0, // top
1, 0, 0, // right
0,-1, 0, // bottom
1, 0, 0, // right
0, 1, 0, // top
1, 0, 0, // right
0,-1, 0, // bottom
1, 0, 0, // right
0,-1, 0, // bottom
-1, 0, 0, // left
];
const numVertices = indices.length;
const vertexData = new Float32Array(numVertices * 6); // xyz + normal
for (let i = 0; i < indices.length; ++i) {
const positionNdx = indices[i] * 3;
const position = positions.slice(positionNdx, positionNdx + 3);
vertexData.set(position, i * 6);
const quadNdx = (i / 6 | 0) * 3;
const normal = normals.slice(quadNdx, quadNdx + 3);
vertexData.set(normal, i * 6 + 3);
}
return { vertexData, numVertices };
}
接着更新渲染管线,使用法线属性替代颜色属性:
const pipeline = device.createRenderPipeline({
label: '2 attributes',
layout: 'auto',
vertex: {
module,
buffers: [
{
arrayStride: (3 + 3) * 4, // (position + normal) float32
attributes: [
{shaderLocation: 0, offset: 0, format: 'float32x3'}, // position
{shaderLocation: 1, offset: 12, format: 'float32x3'}, // normal
],
},
],
},
// ...
});
着色器中的光照计算
顶点着色器现在只负责传递法线,片元着色器进行光照计算:
struct Uniforms {
matrix: mat4x4f,
color: vec4f,
lightDirection: vec3f,
};
struct Vertex {
@location(0) position: vec4f,
@location(1) normal: vec3f,
};
struct VSOutput {
@builtin(position) position: vec4f,
@location(0) normal: vec3f,
};
@group(0) @binding(0) `var<uniform>` uni: Uniforms;
@vertex fn vs(vert: Vertex) -> VSOutput {
var vsOut: VSOutput;
vsOut.position = uni.matrix * vert.position;
vsOut.normal = vert.normal;
return vsOut;
}
@fragment fn fs(vsOut: VSOutput) -> @location(0) vec4f {
// 法线是阶段间变量,会被插值,不再是单位向量,需要归一化
let normal = normalize(vsOut.normal);
// 用点积计算光照:法线点乘光照的反方向
let light = dot(normal, -uni.lightDirection);
// 只乘颜色部分,保留 alpha
let color = uni.color.rgb * light;
return vec4f(color, uni.color.a);
}
光照方向被设置为 [-0.5, -0.7, -1](归一化后),意味着光从右上方射向场景内部。
修正旋转后的法线方向
如果物体旋转,但光照方向不变,你会看到物体旋转而光照效果不变——这显然不对。我们需要在顶点着色器中将法线也旋转到世界空间。最简单的想法是用**世界矩阵(world matrix)**乘以法线,并把 w 分量设为 0,以忽略平移影响:
struct Uniforms {
world: mat4x4f,
worldViewProjection: mat4x4f,
color: vec4f,
lightDirection: vec3f,
};
// ...
@vertex fn vs(vert: Vertex) -> VSOutput {
var vsOut: VSOutput;
vsOut.position = uni.worldViewProjection * vert.position;
vsOut.normal = (uni.world * vec4f(vert.normal, 0)).xyz;
return vsOut;
}
对应的 JavaScript 中,我们需要将矩阵分开计算并填充 uniform 缓冲区:
// 世界矩阵直接写入 worldValue
mat4.rotationY(settings.rotation, worldValue);
// 组合视图投影和世界矩阵
mat4.multiply(viewProjectionMatrix, worldValue, worldViewProjectionValue);
这样旋转物体时,法线也会随之转动,光照效果正确。
处理非均匀缩放:法线矩阵(Normal Matrix)
直接用世界矩阵变换法线有一个隐藏问题:当模型发生非均匀缩放时,法线不再垂直于表面,导致光照错误。解决方法是使用世界矩阵的逆转置矩阵(inverse transpose)来变换法线,这个矩阵通常被称为法线矩阵(normalMatrix)。由于我们只关心方向,可以只用 3×3 矩阵来节省空间。
更新着色器,改用 normalMatrix:
struct Uniforms {
normalMatrix: mat3x3f, // 3x3 法线矩阵
worldViewProjection: mat4x4f,
color: vec4f,
lightDirection: vec3f,
};
// ...
@vertex fn vs(vert: Vertex) -> VSOutput {
var vsOut: VSOutput;
vsOut.position = uni.worldViewProjection * vert.position;
vsOut.normal = uni.normalMatrix * vert.normal;
return vsOut;
}
JavaScript 中需要添加矩阵转置和提取 3×3 矩阵的函数:
const mat4 = {
// ...
transpose(m, dst) {
dst = dst || new Float32Array(16);
dst[ 0] = m[ 0]; dst[ 1] = m[ 4]; dst[ 2] = m[ 8]; dst[ 3] = m[12];
dst[ 4] = m[ 1]; dst[ 5] = m[ 5]; dst[ 6] = m[ 9]; dst[ 7] = m[13];
dst[ 8] = m[ 2]; dst[ 9] = m[ 6]; dst[10] = m[10]; dst[11] = m[14];
dst[12] = m[ 3]; dst[13] = m[ 7]; dst[14] = m[11]; dst[15] = m[15];
return dst;
},
// ...
};
const mat3 = {
fromMat4(m, dst) {
dst = dst || new Float32Array(12);
dst[0] = m[0]; dst[1] = m[1]; dst[ 2] = m[ 2];
dst[4] = m[4]; dst[5] = m[5]; dst[ 6] = m[ 6];
dst[8] = m[8]; dst[9] = m[9]; dst[10] = m[10];
return dst;
},
};
注意:WebGPU 中的 3×3 矩阵每个列会填充一个 float 到 4 字节对齐,因此实际占用 12 个 float(每列 3 个 float + 1 个填充),这与内存布局有关。
计算法线矩阵并更新 uniform:
const world = mat4.rotationY(settings.rotation);
mat4.multiply(viewProjectionMatrix, world, worldViewProjectionValue);
mat3.fromMat4(mat4.transpose(mat4.inverse(world)), normalMatrixValue);
因为我们的示例没有缩放,所以视觉上没有明显差别,但这样做是正确且通用的。
方向光照明的完整流程
- 准备顶点数据:包含位置和法线。
- 配置管线:在顶点缓冲区中声明位置和法线两个属性。
- 着色器:顶点着色器将法线变换到世界空间(使用法线矩阵),片元着色器计算点积得到光照强度,并乘以颜色。
- 更新 uniform:将法线矩阵、世界视图投影矩阵、颜色和光照方向写入同一个 uniform 缓冲区。
方向光照明的计算虽然简单,但它是理解更复杂光照模型(点光、聚光灯)的基础。
三个核心优化要点
-
使用法线矩阵(normalMatrix)而非直接使用世界矩阵变换法线
当模型发生非均匀缩放时,直接用世界矩阵变换法线会导致法线不再垂直于表面,光照结果错误。使用世界矩阵的逆转置矩阵(即法线矩阵)可以保证法线始终垂直。同时,用 3×3 矩阵替代 4×4 矩阵可减少 uniform 缓冲区占用,并简化计算。 -
在片元着色器中对插值后的法线进行归一化
顶点着色器输出的法线经过 GPU 插值后长度会发生变化,不再是单位向量。在片元着色器中使用normalize()恢复单位长度,才能得到正确的点积结果,确保光照强度准确。 -
紧凑的 uniform 缓冲区布局与视图管理
将矩阵、颜色、光照方向等多个 uniform 数据打包到同一个缓冲区中,通过预计算偏移量和创建子数组视图(subarray)来分别更新,可以避免多次分配和上传,减少 CPU 与 GPU 之间的数据传输,提高渲染性能。
专业术语检查(GLSL / WebGL / WGSL 对照)
| 英文术语 | 中文翻译 | 说明 |
|---|---|---|
| normal | 法线 | 不是“正常”,是表面朝向的单位向量 |
| dot product | 点积 | 两个向量夹角余弦(当均为单位向量时) |
| unit vector | 单位向量 | 长度为 1 的向量 |
| normalize | 归一化 | 将向量缩放为单位向量 |
| vertex shader | 顶点着色器 | 处理每个顶点的阶段 |
| fragment shader | 片元着色器 | 处理每个像素/片元的阶段 |
| uniform buffer | 统一缓冲区 | WebGPU 中存储 uniform 数据;GLSL 中对应 uniform block |
| inter-stage variable | 阶段间变量 | WebGPU 中顶点着色器输出到片元着色器的数据;GLSL 中对应 varying |
| world matrix | 世界矩阵 | 将物体从模型空间变换到世界空间 |
| view projection matrix | 视图投影矩阵 | 将世界空间坐标变换到裁剪空间 |
| normal matrix | 法线矩阵 | 模型矩阵的逆转置矩阵,用于正确变换法线 |
| transpose | 转置 | 矩阵行列互换 |
| inverse | 逆矩阵 | 矩阵的逆 |
| arrayStride | 数组步长 | 顶点缓冲区中相邻顶点之间的字节数 |
| WGSL | WebGPU 着色语言 | WebGPU 的着色语言,类似 GLSL 但语法不同 |
以上术语在 WebGL/GLSL 中有对应概念,但要注意 WebGPU 使用 WGSL,语法和命名略有差异,例如顶点属性位置使用 @location,而 GLSL 使用 layout(location = ...)。本教程所述光照原理和矩阵运算在两者中完全通用。
