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);

因为我们的示例没有缩放,所以视觉上没有明显差别,但这样做是正确且通用的。


方向光照明的完整流程

  1. 准备顶点数据:包含位置和法线。
  2. 配置管线:在顶点缓冲区中声明位置和法线两个属性。
  3. 着色器:顶点着色器将法线变换到世界空间(使用法线矩阵),片元着色器计算点积得到光照强度,并乘以颜色。
  4. 更新 uniform:将法线矩阵、世界视图投影矩阵、颜色和光照方向写入同一个 uniform 缓冲区。

方向光照明的计算虽然简单,但它是理解更复杂光照模型(点光、聚光灯)的基础。


三个核心优化要点

  1. 使用法线矩阵(normalMatrix)而非直接使用世界矩阵变换法线
    当模型发生非均匀缩放时,直接用世界矩阵变换法线会导致法线不再垂直于表面,光照结果错误。使用世界矩阵的逆转置矩阵(即法线矩阵)可以保证法线始终垂直。同时,用 3×3 矩阵替代 4×4 矩阵可减少 uniform 缓冲区占用,并简化计算。

  2. 在片元着色器中对插值后的法线进行归一化
    顶点着色器输出的法线经过 GPU 插值后长度会发生变化,不再是单位向量。在片元着色器中使用 normalize() 恢复单位长度,才能得到正确的点积结果,确保光照强度准确。

  3. 紧凑的 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数组步长顶点缓冲区中相邻顶点之间的字节数
WGSLWebGPU 着色语言WebGPU 的着色语言,类似 GLSL 但语法不同

以上术语在 WebGL/GLSL 中有对应概念,但要注意 WebGPU 使用 WGSL,语法和命名略有差异,例如顶点属性位置使用 @location,而 GLSL 使用 layout(location = ...)。本教程所述光照原理和矩阵运算在两者中完全通用。