WebGPU 聚光灯照明(Spot Lighting)

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


引言

本文是 点光源照明(Point Lighting) 的延续。如果你还没有阅读前文,建议先阅读点光源照明。

在上一篇文章中,我们为物体表面每个点计算了从光源指向该点的方向向量,然后与表面法线做点积,得到 1(完全朝向光源)、0(垂直)或 -1(背向光源),并用这个值调制表面颜色,实现基础光照。

聚光灯照明只做很小的改动。事实上,如果你对之前的内容有创造性思考,可能已经能自己推导出解决方案。


聚光灯原理

你可以把点光源想象成一个向所有方向发射光线的点。要把它变成聚光灯,只需从这个点选取一个特定方向作为“聚光灯方向”。然后,对光源发出的每一条光线方向,计算它与聚光灯方向的点积,并选择一个阈值(角度限制),如果点积结果大于阈值则照亮,否则不照亮。

下面的图示展示了这个思想:一个向四面八方发射光线的光源,每条光线上标注了它与聚光灯方向的点积。我们选定一个方向和一个角度限制(图中以度数表示),然后将角度限制转换为点积空间(取余弦)。如果光线方向与聚光灯方向的点积大于该点积阈值,就执行光照。

角度到点积空间的转换表:

角度(度)弧度点积空间(余弦值)
00.01.0
22.38.93
45.79.71
671.17.39
901.570.0
1803.14-1.0

然后只需判断:

dotFromDirection = dot(surfaceToLight, -lightDirection)
if (dotFromDirection >= limitInDotSpace) {
   // 执行光照
}

修改着色器

首先在 Uniforms 结构体中添加两个字段:lightDirection(聚光灯方向)和 limit(角度限制的点积值)。

struct Uniforms {
  normalMatrix: mat3x3f,
  worldViewProjection: mat4x4f,
  world: mat4x4f,
  color: vec4f,
  lightWorldPosition: vec3f,
  viewWorldPosition: vec3f,
  shininess: f32,
+  lightDirection: vec3f,
+  limit: f32,
};

然后在片元着色器中,使用 dot 判断是否在聚光灯锥体内部。注意我们比较的是 surfaceToLightDirection 与 -uni.lightDirection 的点积,因为我们需要比较的两个方向在匹配时应指向同一方向。

@fragment fn fs(vsOut: VSOutput) -> @location(0) vec4f {
  // 因为 vsOut.normal 是阶段间变量,会被插值,所以不一定是单位向量。
  // 归一化后重新变为单位向量
  let normal = normalize(vsOut.normal);

  let surfaceToLightDirection = normalize(vsOut.surfaceToLight);
  let surfaceToViewDirection = normalize(vsOut.surfaceToView);
  let halfVector = normalize(
    surfaceToLightDirection + surfaceToViewDirection);

+  var light = 0.0;
+  var specular = 0.0;
+
+  let dotFromDirection = dot(surfaceToLightDirection, -uni.lightDirection);
+  if (dotFromDirection > uni.limit) {
    // 通过法线与光源方向点积计算光照
-    let light = dot(normal, surfaceToLightDirection);
+    light = dot(normal, surfaceToLightDirection);

    specular = dot(normal, halfVector);
    specular = select(
        0.0,                           // 条件为假时的值
        pow(specular, uni.shininess),  // 条件为真时的值
        specular > 0.0);               // 条件
+  }

  // 只乘颜色部分(不乘 alpha)
  let color = uni.color.rgb * light + specular;
  return vec4f(color, uni.color.a);
}

更新 Uniform Buffer

在 JavaScript 侧,需要为新增的两个值分配空间,并更新偏移量。

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

  const uniformValues = new Float32Array(uniformBufferSize / 4);

  // 以 float32 索引为单位的各个 uniform 值的偏移量
  const kNormalMatrixOffset = 0;
  const kWorldViewProjectionOffset = 12;
  const kWorldOffset = 28;
  const kColorOffset = 44;
  const kLightWorldPositionOffset = 48;
  const kViewWorldPositionOffset = 52;
  const kShininessOffset = 55;
+  const kLightDirectionOffset = 56;
+  const kLimitOffset = 59;

  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 lightDirectionValue = uniformValues.subarray(
+      kLightDirectionOffset, kLightDirectionOffset + 3);
+  const limitValue = uniformValues.subarray(
+      kLimitOffset, kLimitOffset + 1);

设置这些值:

colorValue.set([0.2, 1, 0.2, 1]);  // green
lightWorldPositionValue.set([-10, 30, 100]);
viewWorldPositionValue.set(eye);
shininessValue[0] = settings.shininess;
+    limitValue[0] = Math.cos(settings.limit);

// 由于我们没有像大多数聚光灯示例那样有一个平面,
// 所以让聚光灯指向字母 F
{
    const mat = mat4.aim(
        lightWorldPositionValue,
        [
          target[0] + settings.aimOffsetX,
          target[1] + settings.aimOffsetY,
          0,
        ],
        up);
    // 从矩阵获取 z 轴
    // 取反是因为 lookAt 沿 -Z 轴观察
    lightDirectionValue.set(mat.slice(8, 11));
}

这里的 mat4.aim 在相机文章中介绍过。我们让聚光灯指向 F 物体,并添加偏移量便于调试。最后提取矩阵的 z 轴作为聚光灯方向。

添加 UI 控制:

const settings = {
  rotation: degToRad(0),
  shininess: 30,
+    limit: degToRad(15),
+    aimOffsetX: -10,
+    aimOffsetY: 10,
};

const radToDegOptions = { min: -360, max: 360, step: 1, converters: GUI.converters.radToDeg };
+  const limitOptions = { min: 0, max: 90, minRange: 1, 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 });
+  gui.add(settings, 'limit', limitOptions);
+  gui.add(settings, 'aimOffsetX', -50, 50);
+  gui.add(settings, 'aimOffsetY', -50, 50);

软边缘:内外限制

上面的实现会在聚光灯边界处产生非常生硬的过渡:在锥体内就完全照亮,锥体外就完全变黑。为了改善效果,我们可以使用两个限制:内限制(inner limit) 和 外限制(outer limit)。

  • 在 innerLimit 内部,光照强度为 1.0。
  • 在 outerLimit 外部,光照强度为 0.0。
  • 在两者之间,使用线性插值(lerp)从 1.0 过渡到 0.0。

修改着色器:

struct Uniforms {
  normalMatrix: mat3x3f,
  worldViewProjection: mat4x4f,
  world: mat4x4f,
  color: vec4f,
  lightWorldPosition: vec3f,
  viewWorldPosition: vec3f,
  shininess: f32,
  lightDirection: vec3f,
-  limit: f32,
+  innerLimit: f32,
+  outerLimit: f32,
};

...

    let dotFromDirection = dot(surfaceToLightDirection, -uni.lightDirection);
    let limitRange = uni.innerLimit - uni.outerLimit;
    let inLight = saturate((dotFromDirection - uni.outerLimit) / limitRange);

    // 通过法线与光源方向点积计算光照
    let light = inLight * dot(normal, surfaceToLightDirection);

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

这里使用了 saturate 将值钳制在 0 到 1 之间,因此 inLight 在 outerLimit 外为 0,在 innerLimit 内为 1,中间部分平滑过渡。然后将漫反射和镜面反射都乘以 inLight。

更新 uniform buffer 的偏移量:

-  const uniformBufferSize = (12 + 16 + 16 + 4 + 4 + 4 + 4) * 4;
+  const uniformBufferSize = (12 + 16 + 16 + 4 + 4 + 4 + 4 + 4) * 4;
...
  const kShininessOffset = 55;
  const kLightDirectionOffset = 56;
-  const kLimitOffset = 59;
+  const kInnerLimitOffset = 59;
+  const kOuterLimitOffset = 60;
...
-  const limitValue = uniformValues.subarray(
-      kLimitOffset, kLimitOffset + 1);
+  const innerLimitValue = uniformValues.subarray(
+      kInnerLimitOffset, kInnerLimitOffset + 1);
+  const outerLimitValue = uniformValues.subarray(
+      kOuterLimitOffset, kOuterLimitOffset + 1);

设置值:

colorValue.set([0.2, 1, 0.2, 1]);  // green
lightWorldPositionValue.set([-10, 30, 100]);
viewWorldPositionValue.set(eye);
shininessValue[0] = settings.shininess;
-    limitValue[0] = Math.cos(settings.limit);
+    innerLimitValue[0] = Math.cos(settings.innerLimit);
+   outerLimitValue[0] = Math.cos(settings.outerLimit);

现在得到的效果更像一个真正的聚光灯。


使用 smoothstep 进一步优化

WGSL 提供了 smoothstep 函数,它同样返回 0 到 1 之间的值,但接受下界和上界两个参数,并在它们之间执行赫尔米特插值(Hermite interpolation),而非线性插值。

smoothstep(lowerBound, upperBound, value)

使用 smoothstep 替换手动计算:

    let dotFromDirection = dot(surfaceToLightDirection, -uni.lightDirection);
-    let limitRange = uni.innerLimit - uni.outerLimit;
-    let inLight = saturate((dotFromDirection - uni.outerLimit) / limitRange);
+    let inLight = smoothStep(uni.outerLimit, uni.innerLimit, dotFromDirection);

两种方式的区别在于插入曲线:线性插值像下图左侧,而赫尔米特插值(smoothstep)像右侧。

最终效果更平滑。是否使用 smoothstep 取决于具体需求。


注意事项

  1. 除零问题:如果 innerLimit 等于 outerLimit,那么 limitRange 为 0,在着色器中除以 0 是未定义行为。我们必须在 JavaScript 端确保两者不相等(如上例中 GUI 已经处理了这种情况)。
  2. smoothstep 参数顺序:smoothstep(lowerBound, upperBound, value) 要求 lowerBound < upperBound,否则结果未定义。对于聚光灯场景,这通常不会发生,但仍需注意。
  3. 着色器中对 uni.lightDirection 取负是一种常见的“半斤八两”式选择——只要保证比较的两个方向在匹配时指向同一方向即可。

3 个核心优化要点

  1. 角度限制转化为点积空间
    将聚光灯的角度限制(如 20°)预先计算为余弦值,在着色器中仅进行点积与比较运算,完全避免了 GPU 上的反三角函数计算,极大提升着色器性能。

  2. 使用内外限制实现软边缘
    通过 innerLimit 和 outerLimit 两个阈值,利用 saturate 或 smoothstep 在边界区域进行插值,替代简单的 if 分支,既避免了硬边导致的视觉不自然,又减少了动态分支带来的性能开销。

  3. 利用 smoothstep 函数简化并优化插值
    WGSL 内置的 smoothstep 函数提供了赫尔米特插值,相比手动构造线性插值,代码更简洁且过渡更平滑。在实现软边缘时优先考虑使用内置函数,能同时获得可读性和性能收益。


GLSL/WebGL 专业术语检查表

英文原文中文翻译术语类型备注
Spot Lighting聚光灯照明光照模型又称锥形光
Point Lighting点光源照明光照模型向所有方向发光
Directional Lighting方向光照明光照模型平行光
Dot product点积数学运算衡量两向量方向一致性
Surface normal表面法线几何单位向量
Normalize归一化数学运算将向量变为单位向量
Inter-stage variable阶段间变量管线顶点着色器传递到片元着色器
Interpolated插值管线光栅化阶段自动计算
Fragment shader片元着色器着色器WebGPU 中使用 @fragment 标注
Uniform buffer统一缓冲区GPU 资源存储 uniform 数据
WGSLWebGPU Shading Language着色语言WebGPU 专用着色语言
Saturate饱和(钳制到 0-1)内置函数saturate(x) = clamp(x, 0, 1)
Smoothstep平滑阶跃内置函数赫尔米特插值
Hermite interpolation赫尔米特插值数学比线性插值更平滑
Lerp线性插值数学mix 函数的别名
Specular镜面反射光照模型高光部分
Diffuse漫反射光照模型文中通过 dot(normal, lightDir) 得到
Shininess光泽度/锐度材质参数控制高光范围
Half vector半向量光照模型光源方向与视线方向的中间向量
mat4.aim矩阵瞄准函数工具函数构建朝向目标的矩阵
Uniform统一变量着色器变量GPU 上只读的常量数据
Buffer offset缓冲偏移量内存布局以 float32 索引为单位

以上术语均已在文中正确使用,符合 WebGPU/WGSL 规范。若需对照 GLSL/WebGL,需要注意 WebGPU 使用 uniform buffer 的概念与 GLSL 的 uniform 类似,但绑定方式不同;saturate 在 GLSL 中也有对应实现,smoothstep 函数语义一致。


总结:聚光灯本质上是点光源的方向约束版本,通过点积空间限制和软边缘插值,可以高效地实现逼真的锥形光照效果。WebGPU 的内置函数(如 saturate、smoothstep)为我们提供了简洁且优化的实现手段。