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 渲染性能剖析与收口 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。