12.1 光照模型与着色路径
进入光照这一章,先要回答一个最朴素的问题:一个片元最终该是什么颜色?把它写成公式,就是「法线、光方向、视线方向、材质」四个输入映射到一根 RGB 颜色。这一步在引擎里叫着色路径(shading path),它决定了后面所有光照特性——点光、聚光、阴影、集群——最终往哪个公式里塞。
本节不重推光照数学,而是把它在 helio 里落成两条必须互相对齐的实现:一份纯 TS 的 CPU 参考,和一份跑在 RHI 上的 WGSL。CPU 版本当「期望值」,GPU 版本当「被测量」,两者对不上就说明代码写错了。
本章与站内既有内容的分工
第 12 章「光照」最容易写成一篇光照理论综述,先划清边界:
| 既有文章 | 它负责什么 | 本节只做什么 |
|---|---|---|
/graphics-lighting-shadows/ | Lambert→Phong→PBR 的推导、Cook-Torrance BRDF、阴影贴图 / CSM / PCSS 原理 | 只把 Lambert / Blinn-Phong / GGX 三条路径在 helio 上从零实现并跑出像素 |
/graphics-clustered-forward-rendering/ | Forward+ / Tiled / Clustered 的原理、分桶结构与性能取舍 | 不重推原理,12.3 只做引擎侧的集群装配与实测 |
一句话:别人讲「光照公式为什么长这样」,本章讲「在 helio 的 RHI 上怎么把它搭出来、跑出什么数字」。
从零实现:三条着色路径
三条路径按复杂度递进。Lambert 只算漫反射,颜色 = albedo * max(0, N·L) / PI,出的是塑料感的平光。Blinn-Phong 加一个高光项,用半程向量 H = normalize(L + V) 的 pow(max(0, N·H), shininess),高光锐度靠指数调。GGX/Cook-Torrance 换成物理化的微表面高光 D·G·F / (4·N·V·N·L),用粗糙度控制高光形状,掠射角自然变亮。
三条路径共用一套向量工具与材质结构。注意背面剔除:光源在表面背后时 N·L ≤ 0,此时 L + V 可能退化成零向量,必须在归一化之前就返回,否则会触发除零。
// file: src/render/lighting/lighting-model.ts
// tier: L1
// verify: npx vitest run src/render/lighting/lighting-model.test.ts
//
// helio 渲染卷 12.1:光照模型与着色路径的 CPU 参考实现。
// 把「法线 + 光方向 + 视线方向 + 材质」映射到一根 RGB 颜色,覆盖
// Lambert 漫反射、Blinn-Phong 高光与 GGX/Cook-Torrance 高光三条路径。
// 同一套公式稍后原样搬进 WGSL,CPU 算出的值就是 GPU 的期望值。
export type Vec3 = readonly [number, number, number];
export interface Material {
readonly albedo: Vec3; // 漫反射反照率(线性空间)
readonly specular: number; // Blinn-Phong 高光强度
readonly shininess: number; // Blinn-Phong 高光指数
readonly roughness: number; // GGX 粗糙度
readonly f0: Vec3; // 垂直入射菲涅耳反射率
}
export const DEFAULT_MATERIAL: Material = {
albedo: [0.8, 0.8, 0.8], specular: 0.5, shininess: 32,
roughness: 0.35, f0: [0.04, 0.04, 0.04],
};
export function dot(a: Vec3, b: Vec3): number {
return a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
}
export function normalize(v: Vec3): Vec3 {
const len = Math.hypot(v[0], v[1], v[2]);
if (len === 0) throw new RangeError('零向量无法归一化');
return [v[0] / len, v[1] / len, v[2] / len];
}
export function add(a: Vec3, b: Vec3): Vec3 {
return [a[0] + b[0], a[1] + b[1], a[2] + b[2]];
}
const saturate = (x: number): number => Math.min(1, Math.max(0, x));
/** Lambert 漫反射:albedo * max(0, N·L) / PI。 */
export function lambertDiffuse(normal: Vec3, lightDir: Vec3, albedo: Vec3): Vec3 {
const k = Math.max(0, dot(normalize(normal), normalize(lightDir))) / Math.PI;
return [albedo[0] * k, albedo[1] * k, albedo[2] * k];
}
/** Blinn-Phong 高光:specular * pow(max(0, N·H), shininess),H 为半程向量。 */
export function blinnPhongSpecular(normal: Vec3, lightDir: Vec3, viewDir: Vec3, m: Material): Vec3 {
const n = normalize(normal);
const l = normalize(lightDir);
const v = normalize(viewDir);
if (dot(n, l) <= 0) return [0, 0, 0]; // 背面无高光(此时 L+V 可能是零向量)
const h = normalize(add(l, v));
const spec = m.specular * Math.pow(Math.max(0, dot(n, h)), m.shininess);
return [spec, spec, spec];
}
/** 菲涅耳 Schlick 近似:F = F0 + (1 - F0) * (1 - cosθ)^5。 */
export function fresnelSchlick(cosTheta: number, f0: Vec3): Vec3 {
const k = Math.pow(1 - saturate(cosTheta), 5);
return [f0[0] + (1 - f0[0]) * k, f0[1] + (1 - f0[1]) * k, f0[2] + (1 - f0[2]) * k];
}
/** GGX/Trowbridge-Reitz 法线分布 D。 */
export function ggxDistribution(nDotH: number, roughness: number): number {
const a2 = roughness ** 4;
const nh = saturate(nDotH);
const denom = nh * nh * (a2 - 1) + 1;
return a2 / (Math.PI * denom * denom);
}
/** Smith 几何项,Schlick-GGX 近似,k = (r+1)^2/8。 */
export function ggxGeometry(nDotV: number, nDotL: number, roughness: number): number {
const k = ((roughness + 1) ** 2) / 8;
const gv = saturate(nDotV) / (saturate(nDotV) * (1 - k) + k);
const gl = saturate(nDotL) / (saturate(nDotL) * (1 - k) + k);
return gv * gl;
}
/** Cook-Torrance 高光:D * G * F / (4 * N·V * N·L)。 */
export function cookTorranceSpecular(normal: Vec3, lightDir: Vec3, viewDir: Vec3, m: Material): Vec3 {
const n = normalize(normal);
const l = normalize(lightDir);
const v = normalize(viewDir);
const nDotL = dot(n, l);
const nDotV = dot(n, v);
if (nDotL <= 0 || nDotV <= 0) return [0, 0, 0];
const h = normalize(add(l, v));
const d = ggxDistribution(Math.max(0, dot(n, h)), m.roughness);
const g = ggxGeometry(nDotV, nDotL, m.roughness);
const f = fresnelSchlick(Math.max(0, dot(v, h)), m.f0);
const s = (d * g) / (4 * nDotV * nDotL);
return [f[0] * s, f[1] * s, f[2] * s];
}
export type ShadingPath = 'lambert' | 'blinn-phong' | 'ggx';
/** 三条路径的入口:漫反射 + 高光,再乘光源颜色与强度,结果在线性空间。 */
export function shade(
path: ShadingPath, normal: Vec3, lightDir: Vec3, viewDir: Vec3,
lightColor: Vec3, lightIntensity: number, material: Material,
): Vec3 {
const diffuse = lambertDiffuse(normal, lightDir, material.albedo);
const spec = path === 'lambert' ? ([0, 0, 0] as Vec3)
: path === 'blinn-phong' ? blinnPhongSpecular(normal, lightDir, viewDir, material)
: cookTorranceSpecular(normal, lightDir, viewDir, material);
const lit = add(diffuse, spec);
return [lit[0] * lightColor[0] * lightIntensity, lit[1] * lightColor[1] * lightIntensity, lit[2] * lightColor[2] * lightIntensity];
}
这份实现有 13 条单测护着:N·L ≤ 0 时漫反射归零、正对光时漫反射恰为 albedo/π、菲涅耳在垂直入射回 F0、粗糙度越小高光越锐等。跑法 npx vitest run src/render/lighting/lighting-model.test.ts。
可运行产物:CPU 与 GPU 在真实设备上对齐
把同一套公式写进 WGSL。着色器用全屏三角形加解析球体求交:每个像素投一条视线,命中单位球就取该点法线着色,三条路径由 uniform 的 path 选择。CPU 与 GPU 用同一组常量(albedo 0.8、specular 0.5、shininess 32、roughness 0.35、F0 = 0.04),光方向 normalize(1,1,1)。
// file: shaders/lighting-model.wgsl
// tier: L2
// verify: node tools/run-l2.mjs src/render/lighting/lighting-model-l2.ts
//
// helio 渲染卷 12.1:全屏三角形 + 解析球体求交,片元里内联三条着色路径
// (lambert / blinn-phong / ggx),由 uniform 的 path 选择,颜色写线性值。
struct Uniforms {
path: vec4<u32>, // x: 0=lambert 1=blinn-phong 2=ggx
eye: vec3<f32>,
aspect: f32,
fov: vec2<f32>, // x: tan(半视场角)
res: vec2<f32>,
};
@group(0) @binding(0) var<uniform> u: Uniforms;
const PI: f32 = 3.14159265359;
const ALBEDO: vec3<f32> = vec3<f32>(0.8, 0.8, 0.8);
const SPECULAR: f32 = 0.5;
const SHININESS: f32 = 32.0;
const ROUGHNESS: f32 = 0.35;
const F0: vec3<f32> = vec3<f32>(0.04, 0.04, 0.04);
const LIGHT_DIR: vec3<f32> = vec3<f32>(0.577350269, 0.577350269, 0.577350269);
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 lambert(n: vec3<f32>, l: vec3<f32>) -> vec3<f32> {
return ALBEDO * (max(0.0, dot(n, l)) / PI);
}
fn blinn_phong(n: vec3<f32>, l: vec3<f32>, v: vec3<f32>) -> vec3<f32> {
if (dot(n, l) <= 0.0) { return vec3<f32>(0.0); }
let ndh = max(0.0, dot(n, normalize(l + v)));
return vec3<f32>(SPECULAR * pow(ndh, SHININESS));
}
fn fresnel(cos_theta: f32) -> vec3<f32> {
let k = pow(1.0 - clamp(cos_theta, 0.0, 1.0), 5.0);
return F0 + (vec3<f32>(1.0) - F0) * k;
}
fn ggx_d(ndh: f32) -> f32 {
let a2 = pow(ROUGHNESS, 4.0);
let nh = clamp(ndh, 0.0, 1.0);
let denom = nh * nh * (a2 - 1.0) + 1.0;
return a2 / (PI * denom * denom);
}
fn ggx_g(ndv: f32, ndl: f32) -> f32 {
let k = pow(ROUGHNESS + 1.0, 2.0) / 8.0;
let gv = clamp(ndv, 0.0, 1.0) / (clamp(ndv, 0.0, 1.0) * (1.0 - k) + k);
let gl = clamp(ndl, 0.0, 1.0) / (clamp(ndl, 0.0, 1.0) * (1.0 - k) + k);
return gv * gl;
}
fn cook_torrance(n: vec3<f32>, l: vec3<f32>, v: vec3<f32>) -> vec3<f32> {
let ndl = dot(n, l);
let ndv = dot(n, v);
if (ndl <= 0.0 || ndv <= 0.0) { return vec3<f32>(0.0); }
let h = normalize(l + v);
let d = ggx_d(max(0.0, dot(n, h)));
let g = ggx_g(ndv, ndl);
let f = fresnel(max(0.0, dot(v, h)));
return f * (d * g / (4.0 * ndv * ndl));
}
@fragment
fn fs(in: VSOut) -> @location(0) vec4<f32> {
let ndc = vec2<f32>((in.pos.x / u.res.x) * 2.0 - 1.0, 1.0 - (in.pos.y / u.res.y) * 2.0);
let dir = normalize(vec3<f32>(ndc.x * u.aspect * u.fov.x, ndc.y * u.fov.x, -1.0));
let o = u.eye;
let b = dot(o, dir);
let disc = b * b - (dot(o, o) - 1.0);
if (disc < 0.0) { return vec4<f32>(0.05, 0.05, 0.05, 1.0); }
let hit = o + dir * (-b - sqrt(disc));
let n = normalize(hit);
let v = normalize(o - hit);
var color = lambert(n, LIGHT_DIR);
if (u.path.x == 1u) { color = color + blinn_phong(n, LIGHT_DIR, v); }
if (u.path.x == 2u) { color = color + cook_torrance(n, LIGHT_DIR, v); }
return vec4<f32>(color, 1.0);
}
渲染目标是 rgba8unorm,线性颜色直接按 round(c * 255) 落字节。取 65×65 的画布,像素 (32,32) 的中心恰好落在 ndc (0,0),于是它的法线是 (0,0,1)、视线也是 (0,0,1),CPU 侧可以精确复算同一个值。下面的脚本把 CPU 与 GPU 逐条比对:
// file: src/render/lighting/lighting-model-l2.ts
// tier: L2
// verify: node tools/run-l2.mjs src/render/lighting/lighting-model-l2.ts
//
// helio 渲染卷 12.1:在真实 WebGPU 设备上跑三条着色路径,回读像素,与
// CPU 参考实现逐个比对。CPU 当期望值、GPU 当被测量,二者须在 ±2/255 内一致。
// 需在浏览器中经本地 http 服务运行,命令行不可复现。
import { DEFAULT_MATERIAL, normalize, shade, type ShadingPath, type Vec3 } from './lighting-model.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; // 奇数边长:像素 (32,32) 中心恰好落在 ndc (0,0)
const H = 65;
const EYE: Vec3 = [0, 0, 3];
const LIGHT_DIR = normalize([1, 1, 1]);
const PATHS: readonly ShadingPath[] = ['lambert', 'blinn-phong', 'ggx'];
/** 用 CPU 参考实现算出中心像素(ndc=0,0)的期望字节值。 */
function cpuCenterBytes(path: ShadingPath): [number, number, number] {
const c = shade(path, [0, 0, 1], LIGHT_DIR, [0, 0, 1], [1, 1, 1], 1, DEFAULT_MATERIAL);
const to8 = (x: number): number => Math.round(Math.min(1, Math.max(0, x)) * 255);
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/lighting-model.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 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 bpr = 512; // bytesPerRow 必须是 256 的倍数(65*4=260 会被拒绝)
const readback = device.createBuffer({ size: bpr * H, usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ });
const renderPath = async (pathIndex: number): Promise<number[]> => {
const bytes = new ArrayBuffer(48);
new Uint32Array(bytes, 0, 4)[0] = pathIndex;
const f = new Float32Array(bytes);
f[4] = EYE[0]; f[5] = EYE[1]; f[6] = EYE[2]; f[7] = W / H; f[8] = 0.5;
f[10] = W; f[11] = H;
const ub = device.createBuffer({ size: 48, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
device.queue.writeBuffer(ub, 0, bytes);
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();
const off = 32 * bpr + 32 * 4;
return Array.from(px.slice(off, off + 4));
};
const table: Record<string, unknown> = {};
for (let i = 0; i < PATHS.length; i++) {
const gpu = await renderPath(i);
const cpu = cpuCenterBytes(PATHS[i]!);
const diff = Math.max(Math.abs(gpu[0]! - cpu[0]!), Math.abs(gpu[1]! - cpu[1]!), Math.abs(gpu[2]! - cpu[2]!));
table[PATHS[i]!] = { cpu, gpu: gpu.slice(0, 3), maxDiff: diff };
push(`${PATHS[i]} CPU≈GPU(±2)`, diff <= 2, `cpu=${cpu} gpu=${gpu.slice(0, 3)} diff=${diff}`);
}
data.table = table;
device.destroy();
return { checks, data };
}
在 headless Chromium(本地 http 服务、带 --enable-unsafe-webgpu)里跑,本机实测:
PASS wgsl 0 error 0
PASS lambert CPU≈GPU(±2) cpu=37,37,37 gpu=37,37,37 diff=0
PASS blinn-phong CPU≈GPU(±2) cpu=40,40,40 gpu=40,40,40 diff=0
PASS ggx CPU≈GPU(±2) cpu=38,38,38 gpu=38,38,38 diff=0
三条路径的中心像素逐字节相等(diff 都是 0):Lambert 最暗(37),Blinn-Phong 因高光抬高到 40,GGX 介于两者之间(38)。这个顺序本身就说明问题——同一材质、同一光照下,高光模型换了,画面就换了;而 CPU 与 GPU 在同机 SwiftShader 上给出的字节完全一致,说明 WGSL 与 TS 两份实现没有偏差。
(本节所有数字均来自本机实跑,运行环境见附录 C;src/render/lighting/lighting-model-l2.ts 需在浏览器中运行,命令行不可复现。)
搞清楚了「一个片元怎么被着色」,下一个问题是「光照从哪来、随距离怎么变弱」。下一节实现方向光、点光、聚光三类光源与各自的衰减。
阅读导航:上一节:回到目录 · 下一节:12.2 光源类型与衰减 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。