WebGPU 点光源照明(Point Lighting)

原文作者:webgpufundamentals Contributors
原文链接:https://webgpufundamentals.org/webgpu/lessons/webgpu-lighting-point.html


本文是《WebGPU 方向光照明》的续篇。如果没有阅读前文,建议先从方向光照明开始。

在上一篇文章中,我们介绍了方向光(Directional Light)——光线来自统一的固定方向,并在渲染前设置该方向。如果我们不设置光的方向,而是在 3D 空间中选取一个点作为光源位置,并在着色器中为模型表面每个可见点计算从该点到表面的方向,就得到了点光源(Point Light)。

旋转上图表面可以看到,每个表面点都有不同的“表面到光源”向量。对表面法线和每个表面到光源向量求点积,会得到表面上每个点不同的光照值。

接下来我们实现它。

从方向光到点光

首先需要光源位置。修改 Uniforms 结构体,将 lightDirection 替换为 lightPosition:

struct Uniforms {
  normalMatrix: mat3x3f,
  worldViewProjection: mat4x4f,
  color: vec4f,
-  lightDirection: vec3f,
+  lightPosition: vec3f,
};

同时还需要计算表面点的世界坐标位置,因此添加 world 矩阵:

struct Uniforms {
  normalMatrix: mat3x3f,
  worldViewProjection: mat4x4f,
+  world: mat4x4f,
  color: vec4f,
  lightPosition: vec3f,
};

在顶点着色器中计算表面世界坐标,并计算从表面到光源的向量:

struct Uniforms {
  normalMatrix: mat3x3f,
  worldViewProjection: mat4x4f,
*  world: mat4x4f,
  color: vec4f,
*  lightPosition: vec3f,
};

struct Vertex {
  @location(0) position: vec4f,
  @location(1) normal: vec3f,
};

struct VSOutput {
  @builtin(position) position: vec4f,
  @location(0) normal: vec3f,
*  @location(1) surfaceToLight: vec3f,
};

@group(0) @binding(0) `var<uniform>` uni: Uniforms;

@vertex fn vs(vert: Vertex) -> VSOutput {
  var vsOut: VSOutput;
  vsOut.position = uni.worldViewProjection * vert.position;

  // 将法线变换到世界空间并传递给片元着色器
  vsOut.normal = uni.normalMatrix * vert.normal;

*  // 计算表面点的世界坐标
*  let surfaceWorldPosition = (uni.world * vert.position).xyz;
*
*  // 计算表面到光源的向量并传给片元着色器
*  vsOut.surfaceToLight = uni.lightPosition - surfaceWorldPosition;

  return vsOut;
}

在片元着色器中,我们需要将 surfaceToLight 归一化,因为它经过插值后不再是单位向量。然后计算漫反射光照:

@fragment fn fs(vsOut: VSOutput) -> @location(0) vec4f {
  // vsOut.normal 是阶段间变量,经过插值后不是单位向量
  // 归一化使其重新成为单位向量
  let normal = normalize(vsOut.normal);

  let surfaceToLightDirection = normalize(vsOut.surfaceToLight);

  // 通过法线与指向光源方向的点积计算漫反射光照
  let light = dot(normal, surfaceToLightDirection);

  // 只将颜色部分(不含 alpha)乘以光照
  let color = uni.color.rgb * light;
  return vec4f(color, uni.color.a);
}

接下来更新 uniform 缓冲区的大小、偏移量和视图:

-  const uniformBufferSize = (12 + 16 + 4 + 4) * 4;
+  const uniformBufferSize = (12 + 16 + 16 + 4 + 4) * 4;
  const uniformBuffer = device.createBuffer({
    label: 'uniforms',
    size: uniformBufferSize,
    usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
  });

  const uniformValues = new Float32Array(uniformBufferSize / 4);

  // 各 uniform 值在 Float32 数组中的偏移量
  const kNormalMatrixOffset = 0;
  const kWorldViewProjectionOffset = 12;
-  const kColorOffset = 28;
-  const kLightDirectionOffset = 32;
+  const kWorldOffset = 28;
+  const kColorOffset = 44;
+  const kLightPositionOffset = 48;

  const normalMatrixValue = uniformValues.subarray(
      kNormalMatrixOffset, kNormalMatrixOffset + 12);
  const worldViewProjectionValue = uniformValues.subarray(
      kWorldViewProjectionOffset, kWorldViewProjectionOffset + 16);
+  const worldValue = uniformValues.subarray(
+      kWorldOffset, kWorldOffset + 16);
  const colorValue = uniformValues.subarray(kColorOffset, kColorOffset + 4);
-  const lightDirectionValue =
-      uniformValues.subarray(kLightDirectionOffset, kLightDirectionOffset + 3);
+  const lightPositionValue =
+      uniformValues.subarray(kLightPositionOffset, kLightPositionOffset + 3);

设置对应的值:

    const eye = [100, 150, 200];
    const target = [0, 35, 0];
    const up = [0, 1, 0];

    // 计算视图矩阵
    const viewMatrix = mat4.lookAt(eye, target, up);

    // 合并视图和投影矩阵
    const viewProjectionMatrix = mat4.multiply(projection, viewMatrix);

    // 计算世界矩阵
-    const world = mat4.rotationY(settings.rotation);
+    const world = mat4.rotationY(settings.rotation, worldValue);

    // 合并世界矩阵与视图投影矩阵
    mat4.multiply(viewProjectionMatrix, world, worldViewProjectionValue);

    // 计算法线矩阵(世界矩阵的逆转置)
    mat3.fromMat4(mat4.transpose(mat4.inverse(world)), normalMatrixValue);

    colorValue.set([0.2, 1, 0.2, 1]);  // 绿色
-    lightDirectionValue.set(vec3.normalize([-0.5, -0.7, -1]));
+    lightPositionValue.set([-10, 30, 100]);

    // 将 uniform 值上传到 uniform 缓冲区
    device.queue.writeBuffer(uniformBuffer, 0, uniformValues);

现在,每个片元都会根据其表面点与点光源的位置关系计算不同的光照方向。

镜面高光(Specular Highlighting)

有了点光源,我们可以添加镜面高光效果。现实世界中,如果物体表面有光泽,当光源恰好反射到观察者眼睛时,会看到类似镜面的高光。

我们可以通过计算光线是否反射到观察方向来模拟。如果表面到光源的方向与表面到观察方向完全对称,那么它们的**半向量(Half Vector)**会与表面法线重合,点积为 1。

首先需要将观察者/相机世界位置传入着色器,并在顶点着色器中计算表面到观察方向:

struct Uniforms {
  normalMatrix: mat3x3f,
  worldViewProjection: mat4x4f,
  world: mat4x4f,
  color: vec4f,
  lightPosition: vec3f,
+  viewWorldPosition: vec3f,
};

struct Vertex {
  @location(0) position: vec4f,
  @location(1) normal: vec3f,
};

struct VSOutput {
  @builtin(position) position: vec4f,
  @location(0) normal: vec3f,
  @location(1) surfaceToLight: vec3f,
+  @location(2) surfaceToView: vec3f,
};

@group(0) @binding(0) `var<uniform>` uni: Uniforms;

@vertex fn vs(vert: Vertex) -> VSOutput {
  var vsOut: VSOutput;
  vsOut.position = uni.worldViewProjection * vert.position;

  // 将法线变换到世界空间并传递给片元着色器
  vsOut.normal = uni.normalMatrix * vert.normal;

  // 计算表面点的世界坐标
  let surfaceWorldPosition = (uni.world * vert.position).xyz;

  // 计算表面到光源的向量并传给片元着色器
  vsOut.surfaceToLight = uni.lightPosition - surfaceWorldPosition;

+  // 计算表面到观察者的向量并传给片元着色器
+  vsOut.surfaceToView = uni.viewWorldPosition - surfaceWorldPosition;

  return vsOut;
}

在片元着色器中,计算半向量并求与法线的点积得到镜面强度:

@fragment fn fs(vsOut: VSOutput) -> @location(0) vec4f {
  // 归一化法线
  let normal = normalize(vsOut.normal);

  let surfaceToLightDirection = normalize(vsOut.surfaceToLight);

  // 漫反射光照
  let light = dot(normal, surfaceToLightDirection);

  let surfaceToViewDirection = normalize(vsOut.surfaceToView);
  let halfVector = normalize(
    surfaceToLightDirection + surfaceToViewDirection);
  let specular = dot(normal, halfVector);

  // 颜色 = 基础色 * 漫反射 + 镜面高光
  let color = uni.color.rgb * light + specular;
  return vec4f(color, uni.color.a);
}

此时镜面高光会非常亮。我们可以通过将点积结果进行幂运算来压缩高光范围,使其从线性衰减变为指数衰减。这个指数称为 shininess(光泽度)。

在 WGSL 中,对负数进行幂运算(如 pow(-5, 2.5))会产生未定义值或 NaN,因此需要使用 select 进行条件判断:

@fragment fn fs(vsOut: VSOutput) -> @location(0) vec4f {

  ...

  var specular = dot(normal, halfVector);
  specular = select(
      0.0,                           // 条件为 false 时的值
      pow(specular, uni.shininess),  // 条件为 true 时的值
      specular > 0.0);               // 条件

添加 shininess 到 uniform 结构,并更新缓冲区布局:

struct Uniforms {
  normalMatrix: mat3x3f,
  worldViewProjection: mat4x4f,
  world: mat4x4f,
  color: vec4f,
  lightWorldPosition: vec3f,
  viewWorldPosition: vec3f,
+  shininess: f32,
};
  const kNormalMatrixOffset = 0;
  const kWorldViewProjectionOffset = 12;
  const kWorldOffset = 28;
  const kColorOffset = 44;
  const kLightWorldPositionOffset = 48;
  const kViewWorldPositionOffset = 52;
+  const kShininessOffset = 55;

  const normalMatrixValue = uniformValues.subarray(
      kNormalMatrixOffset, kNormalMatrixOffset + 12);
  const worldViewProjectionValue = uniformValues.subarray(
      kWorldViewProjectionOffset, kWorldViewProjectionOffset + 16);
  const worldValue = uniformValues.subarray(
      kWorldOffset, kWorldOffset + 16);
  const colorValue = uniformValues.subarray(kColorOffset, kColorOffset + 4);
  const lightWorldPositionValue = uniformValues.subarray(
      kLightWorldPositionOffset, kLightWorldPositionOffset + 3);
  const viewWorldPositionValue = uniformValues.subarray(
      kViewWorldPositionOffset, kViewWorldPositionOffset + 3);
+  const shininessValue = uniformValues.subarray(
+      kShininessOffset, kShininessOffset + 1);

...

  const settings = {
    rotation: degToRad(0),
+    shininess: 30,
  };

  const radToDegOptions = { min: -360, max: 360, step: 1, converters: GUI.converters.radToDeg };

  const gui = new GUI();
  gui.onChange(render);
  gui.add(settings, 'rotation', radToDegOptions);
+  gui.add(settings, 'shininess', { min: 1, max: 250 });

...

  function render() {

   ...

+    shininessValue[0] = settings.shininess;

这样,我们就得到了可调节光泽度的点光源效果。接下来可以学习聚光灯照明。


三个核心优化要点

  1. 在顶点着色器中完成世界空间向量计算并利用插值传递
    将表面世界坐标、表面到光源、表面到观察者等向量在顶点着色器中计算,通过阶段间变量传递给片元着色器。虽然插值会使其不再是单位向量,需要在片元着色器中归一化,但这避免了每个片元重复执行矩阵乘法,显著减少片元着色器计算量。

  2. 使用半向量(Half Vector)代替反射向量计算镜面高光
    传统镜面高光需要计算反射光线并判断是否进入观察者眼睛。使用半向量(表面到光源方向与表面到观察方向之和的归一化向量)与法线点积,可近似得到反射效果,避免了 reflect 函数和多次点积的计算,提升了着色器效率。

  3. 对点积结果进行条件判断,避免负底数幂运算
    在镜面高光计算中,点积结果可能为负。直接对负数进行小数次幂(如 pow(-x, shininess))在 WGSL 中会得到未定义值或 NaN,可能导致渲染错误。使用 select 判断点积大于 0 时才计算幂,否则返回 0,既保证了数值稳定性,又减少了无效计算。


GLSL / WebGL 专业术语检查

英文术语规范中文译名
Point Light点光源
Directional Light方向光
Dot Product点积
Normalize归一化
Vertex Shader顶点着色器
Fragment Shader片元着色器
Inter-stage Variable阶段间变量(插值变量)
Uniform Buffer统一缓冲区
Specular Highlight镜面高光
Half Vector半向量
Shininess光泽度 / 高光指数
World Space世界空间
Normal Matrix法线矩阵
World-View-Projection Matrix世界-视图-投影矩阵
Diffuse Lighting漫反射光照
Unit Vector单位向量

注:原文涉及 WebGPU / WGSL 技术栈,但其中大量光照计算概念与 GLSL/WebGL 通用,上述术语同样适用于两者。翻译时保持了行业通用叫法,如“点积”、“归一化”、“镜面高光”等,避免生僻或歧义表达。