《游戏引擎实现原理》12.2 光源类型与衰减

上一节把着色路径打通,这一节解决「光从哪来、怎么变弱」。方向光没有距离衰减,点光用 UE4 的窗口化反平方,聚光再叠一个锥形衰减。本节在 helio 里实现三类光源的衰减公式,并用一个 compute 着色器在真实 WebGPU 设备上回读每个距离的衰减值,与 CPU 参考逐样本比对。

12.2 光源类型与衰减

12.1 解决了「一个片元怎么着色」,但着色公式里那个 lightColor 从哪来、随距离怎么变化,还没回答。这一节把光源拆成三类:方向光(太阳,只有方向没有位置,衰减恒为 1)、点光(灯泡,随距离衰减)、聚光(手电筒,在点光基础上再乘一个锥形衰减)。

三类光源在引擎里的差别不在着色公式,而在衰减因子:把同一个辐照度送到片元之前,先乘以一个 0 到 1 的系数。这个系数怎么定义,直接决定光斑的形状和性能——衰减掉得太慢,远处的光永远剔不掉;衰减在边界跳变,画面会出现硬边。

三类光源的衰减因子

方向光最简单:没有距离概念,衰减恒等于 1。点光用窗口化反平方(UE4 风格):

ratio  = d / range
window = max(0, 1 - ratio^4)
atten  = window^2 / (d^2 + 1)

分母的 d^2 + 1 是标准反平方,+1 让 d = 0 处不除零;分子的窗口项在 d = range 处把衰减压到严格 0,这样引擎可以放心地用 range 做光源剔除——超出半径的光对画面零贡献。聚光再叠一个锥形项:把 cosAngle(光源朝向与片元方向的夹角余弦)在 [cosOuter, cosInner] 上线性映射到 [0, 1],内锥内为 1、外锥外为 0。

这里有三处工程取舍值得记住。第一,d^2 + 1 里的 +1 是防止 d = 0 处除零——严格的反平方 1/d^2 在原点发散,而真实光源有体积,本来就不该在距离 0 处趋于无穷。第二,窗口项用 ratio^4 而不是线性,是为了让衰减在接近 range 时才快速塌陷,中段保持接近真实反平方的曲线。第三,锥形项只依赖夹角、不依赖距离,所以一个聚光在近处和远处有同样形状的光斑,只是整体亮度按距离衰减——这正是我们想要的直觉。

// file: src/render/lighting/light-attenuation.ts
// tier: L1
// verify: npx vitest run src/render/lighting/light-attenuation.test.ts
//
// helio 渲染卷 12.2:光源类型与衰减的 CPU 参考实现。
// 三类光源(方向光 / 点光 / 聚光)的衰减因子:方向光恒为 1;点光用
// UE4 的窗口化反平方衰减;聚光再乘一个按夹角余弦的锥形衰减。
// 公式与 WGSL 版本逐字对应,供 12.2 的 L2 回读比对。

export type LightKind = 'directional' | 'point' | 'spot';

export interface PointLightConfig {
  readonly range: number; // 影响半径,超过它衰减为 0
}
export interface SpotLightConfig extends PointLightConfig {
  readonly cosInner: number; // 内锥半角的余弦(光斑中心)
  readonly cosOuter: number; // 外锥半角的余弦(光斑边缘)
}

const clamp = (x: number, lo: number, hi: number): number => Math.min(hi, Math.max(lo, x));

/** 方向光没有距离衰减。 */
export function directionalAttenuation(): number {
  return 1;
}

/**
 * 点光的窗口化反平方衰减(UE4 风格):
 *   ratio = d / range
 *   window = max(0, 1 - ratio^4)
 *   return window^2 / (d^2 + 1)
 * 窗口项保证在 d = range 处严格归零,避免无限远处仍有残光。
 */
export function pointAttenuation(distance: number, range: number): number {
  if (!(range > 0)) throw new RangeError(`range 必须为正,收到 ${range}`);
  const d = Math.max(0, distance);
  if (d >= range) return 0;
  const ratio = d / range;
  const window = Math.max(0, 1 - ratio ** 4);
  return (window * window) / (d * d + 1);
}

/**
 * 聚光的锥形衰减:把 cosAngle 在 [cosOuter, cosInner] 上线性映射到 [0, 1]。
 * cosAngle 是「光源指向 → 片元方向」与「光源朝向」夹角的余弦。
 */
export function spotAttenuation(cosAngle: number, cosInner: number, cosOuter: number): number {
  if (!(cosInner > cosOuter)) {
    throw new RangeError(`要求 cosInner > cosOuter,收到 ${cosInner} / ${cosOuter}`);
  }
  const scale = 1 / (cosInner - cosOuter);
  const offset = -cosOuter * scale;
  return clamp(cosAngle * scale + offset, 0, 1);
}

/** 三类光源的总衰减因子。方向光忽略距离,聚光在点光基础上再乘锥形项。 */
export function lightAttenuation(kind: LightKind, distance: number, cosAngle: number, cfg: SpotLightConfig): number {
  if (kind === 'directional') return 1;
  const radial = pointAttenuation(distance, cfg.range);
  if (kind === 'point') return radial;
  return radial * spotAttenuation(cosAngle, cfg.cosInner, cfg.cosOuter);
}

/** 光源在给定距离/夹角处的辐照度 = 强度 * 衰减。 */
export function lightIrradiance(
  kind: LightKind, intensity: number, distance: number, cosAngle: number, cfg: SpotLightConfig,
): number {
  return intensity * lightAttenuation(kind, distance, cosAngle, cfg);
}

这份实现有 12 条单测:d = 0 时衰减为 1、d = range 处严格归零、随距离单调递减、锥形边界取到 1 与 0、cosInner ≤ cosOuter 抛错等。跑法 npx vitest run src/render/lighting/light-attenuation.test.ts。

可运行产物:在 GPU 上回读衰减曲线

把两条公式搬进 WGSL 的一个 compute 着色器:输入是一组 (距离, 夹角余弦) 采样点,每个线程算出 (点光衰减, 聚光衰减, 锥形衰减, 距离) 写回存储缓冲。参数取 range = 10、cosInner = cos(0.3)、cosOuter = cos(0.5)。

// file: shaders/light-attenuation.wgsl
// tier: L2
// verify: node tools/run-l2.mjs src/render/lighting/light-attenuation-l2.ts
//
// helio 渲染卷 12.2:光源类型与衰减的 GPU 版本。
// 一个 compute 着色器,对每一条 (距离, 夹角余弦) 输入算出:
//   x = 点光衰减,y = 聚光衰减,z = 锥形衰减,w = 距离。
// 公式与 src/render/lighting/light-attenuation.ts 逐字对应。
struct Uniforms {
  params: vec4<f32>, // x=range y=cosInner z=cosOuter w=count
};
@group(0) @binding(0) var<uniform> u: Uniforms;
@group(0) @binding(1) var<storage, read> samples: array<vec2<f32>>; // x=距离 y=cosAngle
@group(0) @binding(2) var<storage, read_write> outv: array<vec4<f32>>;

fn point_atten(distance: f32, range: f32) -> f32 {
  if (distance >= range) { return 0.0; }
  let ratio = distance / range;
  let window = max(0.0, 1.0 - pow(ratio, 4.0));
  return (window * window) / (distance * distance + 1.0);
}
fn cone_atten(cos_angle: f32, cos_inner: f32, cos_outer: f32) -> f32 {
  let scale = 1.0 / (cos_inner - cos_outer);
  let offset = -cos_outer * scale;
  return clamp(cos_angle * scale + offset, 0.0, 1.0);
}
@compute @workgroup_size(64)
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
  let i = gid.x;
  if (i >= u32(u.params.w)) { return; }
  let distance = samples[i].x;
  let cos_angle = samples[i].y;
  let radial = point_atten(distance, u.params.x);
  let cone = cone_atten(cos_angle, u.params.y, u.params.z);
  outv[i] = vec4<f32>(radial, radial * cone, cone, distance);
}

下面的脚本把 9 组采样点上传 GPU,回读结果与 CPU 参考逐样本比对,误差上限设 1e-5:

// file: src/render/lighting/light-attenuation-l2.ts
// tier: L2
// verify: node tools/run-l2.mjs src/render/lighting/light-attenuation-l2.ts
//
// helio 渲染卷 12.2:在真实 WebGPU 设备上跑衰减 compute,回读结果,与 CPU
// 参考实现逐样本比对。需在浏览器中经本地 http 服务运行,命令行不可复现。
import { pointAttenuation, spotAttenuation } from './light-attenuation.ts';

export interface L2Check { readonly name: string; readonly ok: boolean; readonly detail: string }
export interface L2Result { readonly checks: L2Check[]; readonly data: Record<string, unknown> }

const RANGE = 10;
const COS_INNER = Math.cos(0.3);
const COS_OUTER = Math.cos(0.5);
// (距离, 夹角余弦):从正中心一路扫到外锥之外。
const SAMPLES: readonly [number, number][] = [
  [0, Math.cos(0.0)], [1, Math.cos(0.1)], [2, Math.cos(0.25)], [4, Math.cos(0.35)],
  [6, Math.cos(0.45)], [8, Math.cos(0.6)], [9.5, Math.cos(0.4)], [10, Math.cos(0.4)],
  [12, Math.cos(0.4)],
];

export async function runL2(): Promise<L2Result> {
  const checks: L2Check[] = [];
  const data: Record<string, unknown> = {};
  const push = (name: string, ok: boolean, detail: unknown): void => {
    checks.push({ name, ok, detail: String(detail) });
  };
  const adapter = await navigator.gpu.requestAdapter();
  if (!adapter) throw new Error('requestAdapter() returned null');
  const device = await adapter.requestDevice();

  const wgsl = await (await fetch('/shaders/light-attenuation.wgsl')).text();
  const module = device.createShaderModule({ code: wgsl });
  const info = await module.getCompilationInfo();
  const errors = info.messages.filter((m) => m.type === 'error').map((m) => `${m.lineNum}:${m.linePos} ${m.message}`);
  data.wgslErrors = errors;
  push('wgsl 0 error', errors.length === 0, errors.length);

  const n = SAMPLES.length;
  const uniform = device.createBuffer({ size: 16, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
  device.queue.writeBuffer(uniform, 0, new Float32Array([RANGE, COS_INNER, COS_OUTER, n]));

  const inBytes = new Float32Array(n * 2);
  SAMPLES.forEach(([d, ca], i) => { inBytes[i * 2] = d; inBytes[i * 2 + 1] = ca; });
  const inBuf = device.createBuffer({ size: inBytes.byteLength, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST });
  device.queue.writeBuffer(inBuf, 0, inBytes);

  const outBuf = device.createBuffer({ size: n * 16, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC });
  const rb = device.createBuffer({ size: n * 16, usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ });

  const pipeline = device.createComputePipeline({ layout: 'auto', compute: { module, entryPoint: 'main' } });
  const bg = device.createBindGroup({ layout: pipeline.getBindGroupLayout(0), entries: [
    { binding: 0, resource: { buffer: uniform } },
    { binding: 1, resource: { buffer: inBuf } },
    { binding: 2, resource: { buffer: outBuf } },
  ] });
  const enc = device.createCommandEncoder();
  const pass = enc.beginComputePass();
  pass.setPipeline(pipeline); pass.setBindGroup(0, bg); pass.dispatchWorkgroups(Math.ceil(n / 64)); pass.end();
  enc.copyBufferToBuffer(outBuf, 0, rb, 0, n * 16);
  device.queue.submit([enc.finish()]);
  await rb.mapAsync(GPUMapMode.READ);
  const gpu = new Float32Array(rb.getMappedRange().slice(0)); rb.unmap();

  let maxDiff = 0;
  const rows: unknown[] = [];
  SAMPLES.forEach(([d, ca], i) => {
    const cpuRadial = pointAttenuation(d, RANGE);
    const cpuCone = spotAttenuation(ca, COS_INNER, COS_OUTER);
    const gRadial = gpu[i * 4]!;
    const gCone = gpu[i * 4 + 2]!;
    maxDiff = Math.max(maxDiff, Math.abs(gRadial - cpuRadial), Math.abs(gCone - cpuCone));
    rows.push({ d, cpuRadial: +cpuRadial.toFixed(6), gpuRadial: +gRadial.toFixed(6), cone: +gCone.toFixed(6) });
  });
  data.rows = rows; data.maxDiff = maxDiff;
  push('GPU≈CPU(1e-5)', maxDiff <= 1e-5, `maxDiff=${maxDiff.toExponential(2)}`);
  push('range 处归零', gpu[7 * 4] === 0 && gpu[8 * 4] === 0, `d=10:${gpu[7 * 4]} d=12:${gpu[8 * 4]}`);
  device.destroy();
  return { checks, data };
}

在 headless Chromium 里跑,本机实测(maxDiff = 3.73e-7):

PASS  wgsl 0 error  0
PASS  GPU≈CPU(1e-5)  maxDiff=3.73e-7
PASS  range 处归零  d=10:0 d=12:0

d    点光衰减     锥形衰减
0    1.000000    1.000000
1    0.499900    1.000000
2    0.199361    1.000000
4    0.055850    0.794688
6    0.020476    0.294063
8    0.005363    0.000000
9.5  0.000377    0.559180
10   0.000000    0.559180
12   0.000000    0.559180

这张表把两件事讲清楚。其一,点光衰减在 range 处硬归零:d = 9.5 还有 0.000377,d = 10 与 d = 12 都恰好是 0——窗口项生效了,引擎可以放心按半径剔除。其二,锥形衰减与距离解耦:d = 8 落在锥外(0),d = 9.5 回到锥内(0.559),说明锥形项只看夹角、不看距离;两者相乘才是聚光的总衰减。CPU 与 GPU 的最大偏差 3.73e-7,是 f32 与 f64 的舍入差,不是公式错。

一句话总结这三类光源的引擎侧意义:方向光不需要剔除,点光与聚光都能用 range 框出一个影响球,这个球正是下一节集群装配的输入。

(本节所有数字均来自本机实跑,运行环境见附录 C;src/render/lighting/light-attenuation-l2.ts 需在浏览器中运行,命令行不可复现。)

单光源的衰减解决了,但场景里往往有上百个光源。下一节把光源按空间装进「集群」,让每个片元只遍历真正影响它的那几个。

阅读导航:上一节:12.1 光照模型与着色路径 · 下一节:12.3 集群光照的引擎侧装配 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「engine」更多文章

  1. 《游戏引擎实现原理》工程与实战 目录
  2. 《游戏引擎实现原理》9.3 选型与自研的判断标准
  3. 《游戏引擎实现原理》9.2 同一个特性在不同引擎里的实现路径