《游戏引擎实现原理》18.2 昼夜循环与光照表现

把「一天中的时刻」映射成一组可直接喂给光照的参数:太阳方向、太阳颜色、天空天顶与地平线颜色、环境光强度。本节在 helio 里从零实现这套映射,再把它原样搬进 WGSL,渲染一天三个时刻的天空并回读像素,要求 CPU 与 GPU 逐像素对齐。

18.2 昼夜循环与光照表现

《微光岛》要有一条时间线:清晨的暖光、正午的顶光、黄昏的橘红、夜晚的幽蓝。这些不是美术手调的常量,而是一段连续的、由时刻派生的函数——只要给出「现在是几点」,就能算出太阳在哪、什么颜色、天空什么色、环境光多亮。

本节不重推光照数学(那在第 12 章光照模型 里),只做一件事:把「时刻 t」这一个标量,展开成一组可以直接喂给已有光照管线的参数。

从时刻到光照参数

时刻取 t ∈ [0,1],0 是午夜、0.25 是日出、0.5 是正午、0.75 是日落。太阳方向由 t 唯一确定;太阳颜色随仰角从暖变白、落到地平线下熄灭;天空则给出天顶与地平线两个锚点色,交给片元按视线高度插值。

这些参数最终要接回既有管线:太阳方向与颜色喂给光源类型与衰减 ,天空梯度与太阳圆盘喂给天空盒,环境光强度喂给间接光。级联阴影的方向同样由这个太阳方向驱动(见级联阴影拟合 )。

// file: src/render/app/day-night.ts
// tier: L1
// verify: npx vitest run src/render/app/day-night.test.ts
//
// helio 渲染卷 18.2:昼夜循环与光照表现。
// 把「一天中的时刻 t ∈ [0,1]」映射成一组可直接喂给光照的光照参数:太阳方向、
// 太阳颜色、天空天顶/地平线颜色、环境光强度。同一套公式稍后原样搬进 WGSL,
// CPU 算出的天空颜色就是 GPU 的期望值。纯计算(L1),Node 26 直跑。

export type Vec3 = readonly [number, number, number];

const clamp01 = (x: number): number => Math.min(1, Math.max(0, x));
const lerp = (a: number, b: number, k: number): number => a + (b - a) * k;
const mix = (a: Vec3, b: Vec3, k: number): Vec3 => [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)];

/** GLSL 同款 smoothstep:e0 以下 0、e1 以上 1,中间三次平滑。 */
export function smoothstep(e0: number, e1: number, x: number): number {
  const t = clamp01((x - e0) / (e1 - e0 || 1));
  return t * t * (3 - 2 * t);
}

export function normalize(v: Vec3): Vec3 {
  const len = Math.hypot(v[0], v[1], v[2]) || 1;
  return [v[0] / len, v[1] / len, v[2] / len];
}

export function dot(a: Vec3, b: Vec3): number {
  return a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
}

/** 时刻 t ∈ [0,1](0=午夜、0.25=日出、0.5=正午、0.75=日落)对应的太阳方向(指向太阳,单位向量)。 */
export function sunDirection(t: number): Vec3 {
  const a = 2 * Math.PI * (t - 0.25);
  return normalize([Math.cos(a), Math.sin(a), 0.35]);
}

/** 太阳颜色:仰角越高越接近白光,贴近地平线偏暖,落到地平线以下熄灭(返回零)。 */
export function sunColor(sunDir: Vec3): Vec3 {
  if (sunDir[1] <= 0) return [0, 0, 0];
  const k = smoothstep(0, 0.35, sunDir[1]); // 0=贴地平线、1=高照
  return mix([1.0, 0.5, 0.22], [1.0, 0.97, 0.9], k);
}

export interface DayNightParams {
  readonly sunDir: Vec3;
  readonly sunColor: Vec3;
  readonly zenith: Vec3; // 天顶颜色
  readonly horizon: Vec3; // 地平线颜色
  readonly ambient: number; // 环境光强度
  readonly groundAlbedo: number; // 地面反照率
}

/** 一天的完整光照参数:太阳方向 / 颜色 + 天空梯度 + 环境光,全部由 t 派生。 */
export function skyState(t: number): DayNightParams {
  const sunDir = sunDirection(t);
  const e = sunDir[1];
  const dayK = smoothstep(-0.05, 0.3, e); // 0=夜、1=昼
  const duskK = Math.max(0, 1 - Math.abs(e) / 0.3); // 地平线附近为 1(暖色)
  const zen = mix([0.02, 0.03, 0.08], [0.22, 0.42, 0.85], dayK);
  const hor = mix([0.05, 0.06, 0.12], [0.62, 0.75, 0.95], dayK);
  return {
    sunDir,
    sunColor: sunColor(sunDir),
    zenith: zen,
    horizon: [clamp01(hor[0] + 0.5 * duskK), clamp01(hor[1] + 0.15 * duskK), clamp01(hor[2] - 0.1 * duskK)],
    ambient: lerp(0.06, 0.18, dayK),
    groundAlbedo: 0.6,
  };
}

/**
 * 天空着色(CPU 参考):给定视线方向返回线性颜色。
 * 向上看:地平线→天顶的梯度,叠加一个太阳圆盘;向下看:朗伯地面 + 环境光。
 * 与 WGSL 的 shade_sky 逐条对应。
 */
export function shadeSky(dir: Vec3, p: DayNightParams): Vec3 {
  const d = normalize(dir);
  const sd = normalize(p.sunDir);
  if (d[1] >= 0) {
    const k = clamp01(d[1]);
    const base = mix(p.horizon, p.zenith, k);
    const s = Math.pow(Math.max(0, dot(d, sd)), 200);
    return [base[0] + p.sunColor[0] * s * 2, base[1] + p.sunColor[1] * s * 2, base[2] + p.sunColor[2] * s * 2];
  }
  const ndl = Math.max(0, sd[1]); // 地面法线 (0,1,0)
  const lit = p.groundAlbedo * ndl;
  return [p.sunColor[0] * lit + p.ambient, p.sunColor[1] * lit + p.ambient, p.sunColor[2] * lit + p.ambient];
}

/** 从像素 ndc 与相机参数构造世界空间视线方向(相机在原点、看向 -z)。 */
export function rayFromNdc(ndcX: number, ndcY: number, aspect: number, tanHalfFov: number): Vec3 {
  return normalize([ndcX * aspect * tanHalfFov, ndcY * tanHalfFov, -1]);
}

单测验证四组性质:正午太阳最高、日出日落贴地平线、午夜在地平线下;太阳颜色正午近白、黄昏偏暖、地平线下熄灭;天空梯度正午天顶偏蓝、黄昏地平线偏暖、午夜整体偏暗;以及正午向上看偏蓝、向下看是被照亮的地面。

可运行产物:一天三个时刻的天空

把同一套公式写进 WGSL。全屏三角形,片元里由像素坐标反算视线方向,向上走「地平线→天顶」梯度加太阳圆盘,向下走朗伯地面加环境光;光照参数(太阳方向 / 颜色、天顶 / 地平线色、环境光)全部由 uniform 传入。

// file: shaders/day-night-sky.wgsl
// tier: L2
// verify: node tools/run-l2.mjs src/render/app/day-night-l2.ts
//
// helio 渲染卷 18.2:昼夜天空。全屏三角形,片元里由像素坐标反算视线方向,
// 向上看走「地平线→天顶」梯度 + 太阳圆盘,向下看走朗伯地面 + 环境光。
// 光照参数(太阳方向/颜色、天顶/地平线色、环境光)全部由 uniform 传入。
struct Uniforms {
  sunDir: vec4<f32>,   // xyz: 指向太阳
  zenith: vec4<f32>,   // xyz: 天顶颜色
  horizon: vec4<f32>,  // xyz: 地平线颜色
  sunColor: vec4<f32>, // xyz: 太阳颜色
  params: vec4<f32>,   // x=aspect y=tanHalfFov z=groundAlbedo w=ambient
  screen: vec4<f32>,   // x=W y=H
};
@group(0) @binding(0) var<uniform> u: Uniforms;

struct VSOut { @builtin(position) pos: vec4<f32> };

@vertex
fn vs(@builtin(vertex_index) i: u32) -> VSOut {
  var tri = array<vec2<f32>, 3>(
    vec2<f32>(-1.0, -1.0), vec2<f32>(3.0, -1.0), vec2<f32>(-1.0, 3.0));
  var o: VSOut;
  o.pos = vec4<f32>(tri[i], 0.0, 1.0);
  return o;
}

fn shade_sky(dir: vec3<f32>) -> vec3<f32> {
  let d = normalize(dir);
  let sd = normalize(u.sunDir.xyz);
  if (d.y >= 0.0) {
    let k = clamp(d.y, 0.0, 1.0);
    let base = mix(u.horizon.xyz, u.zenith.xyz, k);
    let s = pow(max(0.0, dot(d, sd)), 200.0);
    return base + u.sunColor.xyz * s * 2.0;
  }
  let ndl = max(0.0, sd.y); // 地面法线 (0,1,0)
  let lit = u.params.z * ndl;
  return u.sunColor.xyz * lit + vec3<f32>(u.params.w);
}

@fragment
fn fs(in: VSOut) -> @location(0) vec4<f32> {
  let ndcX = (in.pos.x / u.screen.x) * 2.0 - 1.0;
  let ndcY = 1.0 - (in.pos.y / u.screen.y) * 2.0;
  let dir = vec3<f32>(ndcX * u.params.x * u.params.y, ndcY * u.params.y, -1.0);
  return vec4<f32>(shade_sky(dir), 1.0);
}

验收脚本对正午 / 黄昏 / 午夜三个时刻各渲染一帧 65×65 天空,回读像素:CPU 参考实现与 GPU 逐像素比对,并断言正午天顶偏蓝、黄昏地平线偏暖、午夜整体偏暗。

// file: src/render/app/day-night-l2.ts
// tier: L2
// verify: node tools/run-l2.mjs src/render/app/day-night-l2.ts
//
// helio 渲染卷 18.2:在真实 WebGPU 设备上验收昼夜天空。对正午 / 黄昏 / 午夜
// 三个时刻,把 skyState 产出的光照参数喂进 WGSL,渲染 65×65 天空并回读像素:
// CPU 参考实现与 GPU 逐像素对齐,并断言正午天顶偏蓝、黄昏地平线偏暖、午夜偏暗。
// 需在浏览器中经本地 http 服务运行,命令行不可复现。

import { rayFromNdc, shadeSky, skyState, type DayNightParams } from './day-night.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 W = 65;
const H = 65;
const BPR = 512; // 65*4=260,向上取到 256 的倍数
const ASPECT = 1;
const TAN_HALF_FOV = 0.5;
const TIMES = [{ name: 'noon', t: 0.5 }, { name: 'dusk', t: 0.74 }, { name: 'night', t: 0.95 }] as const;
const to8 = (x: number): number => Math.round(Math.min(1, Math.max(0, x)) * 255);

/** 把一份 DayNightParams 打包成 6 个 vec4(96 字节)的 uniform。 */
function packUniforms(p: DayNightParams): Float32Array {
  const u = new Float32Array(24);
  u.set([p.sunDir[0], p.sunDir[1], p.sunDir[2], 0], 0);
  u.set([p.zenith[0], p.zenith[1], p.zenith[2], 0], 4);
  u.set([p.horizon[0], p.horizon[1], p.horizon[2], 0], 8);
  u.set([p.sunColor[0], p.sunColor[1], p.sunColor[2], 0], 12);
  u.set([ASPECT, TAN_HALF_FOV, p.groundAlbedo, p.ambient], 16);
  u.set([W, H, 0, 0], 20);
  return u;
}

/** CPU 参考:像素 (col,row) 的期望字节值。 */
function cpuPixel(col: number, row: number, p: DayNightParams): number[] {
  const ndcX = ((col + 0.5) / W) * 2 - 1;
  const ndcY = 1 - ((row + 0.5) / H) * 2;
  const c = shadeSky(rayFromNdc(ndcX, ndcY, ASPECT, TAN_HALF_FOV), p);
  return [to8(c[0]), to8(c[1]), to8(c[2])];
}

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/day-night-sky.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}`);
  push('wgsl 0 error', errors.length === 0, errors.length);

  const fmt: GPUTextureFormat = 'rgba8unorm';
  const pipeline = device.createRenderPipeline({
    layout: 'auto',
    vertex: { module, entryPoint: 'vs' },
    fragment: { module, entryPoint: 'fs', targets: [{ format: fmt }] },
    primitive: { topology: 'triangle-list' },
  });
  const target = device.createTexture({ size: [W, H], format: fmt, usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC });
  const readback = device.createBuffer({ size: BPR * H, usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ });

  const renderTime = async (p: DayNightParams): Promise<Uint8Array> => {
    const ub = device.createBuffer({ size: 96, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
    device.queue.writeBuffer(ub, 0, packUniforms(p));
    const bg = device.createBindGroup({ layout: pipeline.getBindGroupLayout(0), entries: [{ binding: 0, resource: { buffer: ub } }] });
    const enc = device.createCommandEncoder();
    const pass = enc.beginRenderPass({
      colorAttachments: [{ view: target.createView(), clearValue: { r: 0, g: 0, b: 0, a: 1 }, loadOp: 'clear', storeOp: 'store' }],
    });
    pass.setPipeline(pipeline);
    pass.setBindGroup(0, bg);
    pass.draw(3);
    pass.end();
    enc.copyTextureToBuffer({ texture: target }, { buffer: readback, bytesPerRow: BPR }, [W, H]);
    device.queue.submit([enc.finish()]);
    await readback.mapAsync(GPUMapMode.READ);
    const px = new Uint8Array(readback.getMappedRange().slice(0));
    readback.unmap();
    return px;
  };

  const samples: readonly [number, number][] = [[32, 8], [32, 32], [32, 56]];
  const table: Record<string, unknown> = {};
  for (const { name, t } of TIMES) {
    const p = skyState(t);
    const px = await renderTime(p);
    let maxDiff = 0;
    for (const [col, row] of samples) {
      const off = row * BPR + col * 4;
      const want = cpuPixel(col, row, p);
      for (let k = 0; k < 3; k++) maxDiff = Math.max(maxDiff, Math.abs(px[off + k]! - want[k]!));
    }
    const top = [px[8 * BPR + 128]!, px[8 * BPR + 129]!, px[8 * BPR + 130]!];
    const bot = [px[56 * BPR + 128]!, px[56 * BPR + 129]!, px[56 * BPR + 130]!];
    table[name] = { maxDiff, top, bottom: bot };
    push(`${name} CPU≈GPU(±2)`, maxDiff <= 2, `maxDiff=${maxDiff}`);
  }
  data.table = table;

  const noon = table['noon'] as { top: number[] };
  const dusk = table['dusk'] as { top: number[] };
  const night = table['night'] as { top: number[]; bottom: number[] };
  push('正午天顶偏蓝', noon.top[2]! > noon.top[0]!, `top=${noon.top}`);
  push('黄昏地平线偏暖', dusk.top[0]! > dusk.top[2]!, `top=${dusk.top}`);
  push('午夜整体偏暗', night.top[2]! < 40 && night.bottom[0]! < 40, `top=${night.top} bottom=${night.bottom}`);

  device.destroy();
  return { checks, data };
}

在 headless Chromium(本地 http 服务、带 --enable-unsafe-webgpu)里跑,本机实测:

PASS  wgsl 0 error  0
PASS  noon CPU≈GPU(±2)  maxDiff=0
PASS  dusk CPU≈GPU(±2)  maxDiff=0
PASS  night CPU≈GPU(±2)  maxDiff=0
PASS  正午天顶偏蓝  top=123,162,233
PASS  黄昏地平线偏暖  top=103,67,61
PASS  午夜整体偏暗  top=10,13,27 bottom=15,15,15

三个时刻里,正午正上方看是 123,162,233(蓝压过红),黄昏同方向变成 103,67,61(红压过蓝,正是地平线余晖),午夜压到 10,13,27 的幽蓝。更关键的是三个时刻 CPU 与 GPU 都 maxDiff=0——逐字节相等,说明 TS 与 WGSL 两份实现在同机 SwiftShader 上没有任何偏差。这套参数可以直接接回既有光照管线,天空、太阳与地面就都随时刻连续变化了。

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

画面能动了,但「动得好不好」要用数字说话。下一节给整条渲染链路做性能剖析,并为全卷收口。

阅读导航:上一节:18.1 渲染器装配与场景接入 · 下一节:18.3 渲染性能剖析与收口 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「engine」更多文章

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