WebGPU 聚光灯照明(Spot Lighting)
原作者:webgpufundamentals Contributors
原文链接:https://webgpufundamentals.org/webgpu/lessons/webgpu-lighting-spot.html
引言
本文是 点光源照明(Point Lighting) 的延续。如果你还没有阅读前文,建议先阅读点光源照明。
在上一篇文章中,我们为物体表面每个点计算了从光源指向该点的方向向量,然后与表面法线做点积,得到 1(完全朝向光源)、0(垂直)或 -1(背向光源),并用这个值调制表面颜色,实现基础光照。
聚光灯照明只做很小的改动。事实上,如果你对之前的内容有创造性思考,可能已经能自己推导出解决方案。
聚光灯原理
你可以把点光源想象成一个向所有方向发射光线的点。要把它变成聚光灯,只需从这个点选取一个特定方向作为“聚光灯方向”。然后,对光源发出的每一条光线方向,计算它与聚光灯方向的点积,并选择一个阈值(角度限制),如果点积结果大于阈值则照亮,否则不照亮。
下面的图示展示了这个思想:一个向四面八方发射光线的光源,每条光线上标注了它与聚光灯方向的点积。我们选定一个方向和一个角度限制(图中以度数表示),然后将角度限制转换为点积空间(取余弦)。如果光线方向与聚光灯方向的点积大于该点积阈值,就执行光照。
角度到点积空间的转换表:
| 角度(度) | 弧度 | 点积空间(余弦值) |
|---|---|---|
| 0 | 0.0 | 1.0 |
| 22 | .38 | .93 |
| 45 | .79 | .71 |
| 67 | 1.17 | .39 |
| 90 | 1.57 | 0.0 |
| 180 | 3.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 取决于具体需求。
注意事项
- 除零问题:如果
innerLimit等于outerLimit,那么limitRange为 0,在着色器中除以 0 是未定义行为。我们必须在 JavaScript 端确保两者不相等(如上例中 GUI 已经处理了这种情况)。 smoothstep参数顺序:smoothstep(lowerBound, upperBound, value)要求lowerBound < upperBound,否则结果未定义。对于聚光灯场景,这通常不会发生,但仍需注意。- 着色器中对
uni.lightDirection取负是一种常见的“半斤八两”式选择——只要保证比较的两个方向在匹配时指向同一方向即可。
3 个核心优化要点
-
角度限制转化为点积空间
将聚光灯的角度限制(如 20°)预先计算为余弦值,在着色器中仅进行点积与比较运算,完全避免了 GPU 上的反三角函数计算,极大提升着色器性能。 -
使用内外限制实现软边缘
通过innerLimit和outerLimit两个阈值,利用saturate或smoothstep在边界区域进行插值,替代简单的if分支,既避免了硬边导致的视觉不自然,又减少了动态分支带来的性能开销。 -
利用
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 数据 |
| WGSL | WebGPU 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)为我们提供了简洁且优化的实现手段。
