15.2 色调映射与抗锯齿接入
15.1 搭好了 Pass 链的骨架,现在往里塞两个最常挂的 pass。场景 pass 输出的是线性 HDR——亮度可以远大于 1(太阳、高光、自发光),而显示器只认 [0,1]。中间这一步叫色调映射(tonemapping):把无界的 HDR 压进显示范围,同时尽量保住高光细节与对比。第二个是抗锯齿:几何边缘在像素格上必然产生阶梯,后处理里最省的做法是 FXAA,只在检测到边缘的地方做一次邻域混合。算法层面(各算子的曲线形状、TAA/MSAA/FXAA 的取舍)见 15.1 分工表里的外链,本节只做「在 helio 上把算子写对、把 pass 接上、把像素验对」。
三个色调映射算子
最省的是 Reinhard:x/(1+x),永远落在 [0,1),代价是整体偏灰、高光压得狠。想让它把某个「白点」恰好映到 1,用扩展版 x(1+x/w²)/(1+x)。电影工业更爱 ACES 的近似式(Narkowicz 版本),它对比更强、高光过渡更自然;filmic(Hable 的 Uncharted 2 曲线)则更「胶片」。三者都单调不减、都把大输入夹到 1,差别在曲线的膝盖位置。色调映射之后还要做伽马编码:算子在线性空间里工作,输出得转到 sRGB 显示空间(近似 x^(1/2.2))。顺序不能反——先伽马再色调映射会让高光算法整个错位。
// file: src/render/postfx/tonemap.ts
// tier: L1
// verify: npx vitest run src/render/postfx/tonemap.test.ts
//
// helio 渲染卷 15.2:色调映射算子(线性 HDR -> LDR 显示)。
// 场景 pass 输出的是线性 HDR(可能远大于 1),显示器要的是 [0,1] 的 LDR。
// 本模块给出三个常用算子与曝光 / 伽马辅助;GPU 版在 shaders/postfx-tonemap.wgsl
// 里,两者必须给出同样的结果。
export type TonemapOp = 'reinhard' | 'aces' | 'filmic';
/** 曝光:ev 每 +1,亮度翻倍(2 的幂)。 */
export function applyExposure(rgb: readonly [number, number, number], ev: number): [number, number, number] {
const k = Math.pow(2, ev);
return [rgb[0] * k, rgb[1] * k, rgb[2] * k];
}
export function clamp01(x: number): number { return x < 0 ? 0 : x > 1 ? 1 : x; }
/** Reinhard:x/(1+x),最省也最「灰」,高光压得最狠。 */
export function reinhard(x: number): number { return x / (1 + x); }
/** Reinhard 扩展:带白点 white,令 white 恰好映射到 1。 */
export function reinhardExtended(x: number, white: number): number {
const w2 = white * white;
return (x * (1 + x / w2)) / (1 + x);
}
/** Narkowicz 的 ACES 近似:影视级对比,保留高光细节。 */
export function aces(x: number): number {
const a = 2.51, b = 0.03, c = 2.43, d = 0.59, e = 0.14;
return clamp01((x * (a * x + b)) / (x * (c * x + d) + e));
}
/** Uncharted 2 filmic 曲线(Hable 分段)。 */
export function filmic(x: number): number {
const A = 0.15, B = 0.5, C = 0.1, D = 0.2, E = 0.02, F = 0.3;
const y = ((x * (A * x + C * B) + D * E) / (x * (A * x + B) + D * F)) - E / F;
return clamp01(y);
}
const OPS: Record<TonemapOp, (x: number) => number> = { reinhard, aces, filmic };
/** 逐通道套用算子。 */
export function tonemap(rgb: readonly [number, number, number], op: TonemapOp): [number, number, number] {
const f = OPS[op];
return [f(rgb[0]), f(rgb[1]), f(rgb[2])];
}
/** 线性 -> sRGB 近似伽马编码。 */
export function gammaEncode(x: number, gamma = 2.2): number {
return Math.pow(clamp01(x), 1 / gamma);
}
可运行产物:GPU 色调映射与 FXAA
把这两个 pass 写进着色器。fs_tonemap 读 rgba16float 的 HDR 源,按 uniform 里的 op 选算子、乘曝光、最后取 1/gamma 次幂做伽马;fs_fxaa 读 LDR,取上下左右四个邻域算亮度差,差得越大混得越多——平坦区(差为 0)原样输出,边缘处才被拉向邻域均值。
// file: shaders/postfx-tonemap.wgsl
// tier: L2
// verify: node tools/run-l2.mjs src/render/postfx/tonemap-l2.ts
//
// helio 渲染卷 15.2:色调映射与 FXAA 的着色器。
// fs_tonemap 读 HDR(rgba16float)做曝光 + 算子 + 伽马;fs_fxaa 读 LDR 做一次
// 5 邻域边缘混合。fs_fxaa 不读 uniform,故它由 auto layout 推出的绑定组只有 0/1。
struct Uniforms {
exposure: vec4<f32>, // x = 2^ev, y = op(0 reinhard 1 aces 2 filmic), z = 1/gamma
};
@group(0) @binding(0) var src: texture_2d<f32>;
@group(0) @binding(1) var samp: sampler;
@group(0) @binding(2) var<uniform> u: Uniforms;
const LUMA: vec3<f32> = vec3<f32>(0.299, 0.587, 0.114);
struct VSOut { @builtin(position) pos: vec4<f32>, @location(0) uv: vec2<f32> };
@vertex
fn vs_fullscreen(@builtin(vertex_index) i: u32) -> VSOut {
var p = 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>(p[i], 0.0, 1.0);
o.uv = vec2<f32>((p[i].x + 1.0) * 0.5, 1.0 - (p[i].y + 1.0) * 0.5);
return o;
}
fn reinhard(x: f32) -> f32 { return clamp(x / (1.0 + x), 0.0, 1.0); }
fn aces(x: f32) -> f32 {
let a = 2.51; let b = 0.03; let c = 2.43; let d = 0.59; let e = 0.14;
return clamp((x * (a * x + b)) / (x * (c * x + d) + e), 0.0, 1.0);
}
fn filmic(x: f32) -> f32 {
let A = 0.15; let B = 0.5; let C = 0.1; let D = 0.2; let E = 0.02; let F = 0.3;
let y = ((x * (A * x + C * B) + D * E) / (x * (A * x + B) + D * F)) - E / F;
return clamp(y, 0.0, 1.0);
}
fn op(x: f32, which: f32) -> f32 {
if (which < 0.5) { return reinhard(x); }
if (which < 1.5) { return aces(x); }
return filmic(x);
}
@fragment
fn fs_tonemap(in: VSOut) -> @location(0) vec4<f32> {
let hdr = textureSample(src, samp, in.uv).rgb * u.exposure.x;
let mapped = vec3<f32>(op(hdr.r, u.exposure.y), op(hdr.g, u.exposure.y), op(hdr.b, u.exposure.y));
return vec4<f32>(pow(mapped, vec3<f32>(u.exposure.z)), 1.0);
}
@fragment
fn fs_fxaa(in: VSOut) -> @location(0) vec4<f32> {
let texel = 1.0 / vec2<f32>(textureDimensions(src));
let cM = textureSample(src, samp, in.uv).rgb;
let cN = textureSample(src, samp, in.uv + vec2<f32>(0.0, -texel.y)).rgb;
let cS = textureSample(src, samp, in.uv + vec2<f32>(0.0, texel.y)).rgb;
let cW = textureSample(src, samp, in.uv + vec2<f32>(-texel.x, 0.0)).rgb;
let cE = textureSample(src, samp, in.uv + vec2<f32>(texel.x, 0.0)).rgb;
let lo = min(dot(cM, LUMA), min(min(dot(cN, LUMA), dot(cS, LUMA)), min(dot(cW, LUMA), dot(cE, LUMA))));
let hi = max(dot(cM, LUMA), max(max(dot(cN, LUMA), dot(cS, LUMA)), max(dot(cW, LUMA), dot(cE, LUMA))));
let blend = clamp((hi - lo) * 0.5, 0.0, 1.0);
let avg = (cN + cS + cW + cE) * 0.25;
return vec4<f32>(mix(cM, avg, blend), 1.0);
}
下面的 L2 脚本分三段:先把 rgba16float 的 HDR 源 (2,1,0.5) 过一遍 ACES + 伽马,回读结果与 CPU 的 aces()+gammaEncode() 比对;再把曝光翻倍验证变亮;最后喂一张左黑右白的 LDR 图给 fs_fxaa,检查边界像素被混成中间灰、而平坦区纹丝不动。
// file: src/render/postfx/tonemap-l2.ts
// tier: L2
// verify: node tools/run-l2.mjs src/render/postfx/tonemap-l2.ts
//
// helio 渲染卷 15.2:在真实 WebGPU 设备上跑色调映射与 FXAA。
// (1) HDR 源(rgba16float, 值 2/1/0.5) -> fs_tonemap(ACES+伽马) -> 回读,与 CPU
// 的 aces()+gammaEncode() 比对(容差 2);(2) 提高曝光再跑一次,验证变亮;
// (3) LDR 黑白边 -> fs_fxaa -> 边界像素必须落在 (0,255) 开区间,平坦区不变。
// 需在浏览器中经本地 http 服务运行,命令行不可复现。
import { aces, gammaEncode } from './tonemap.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 = 4, H = 4, EW = 8, EH = 8, BPR = 256;
const HDR: readonly [number, number, number] = [2.0, 1.0, 0.5];
const GAMMA = 1 / 2.2;
/** f32 -> f16 位模式(2/1/0.5 在 f16 中精确可表示)。 */
function f32ToF16(v: number): number {
const buf = new ArrayBuffer(4);
const f = new Float32Array(buf);
const u = new Uint32Array(buf);
f[0] = v;
const x = u[0]!;
const sign = (x >>> 16) & 0x8000;
const exp = (x >>> 23) & 0xff;
const mant = x & 0x7fffff;
if (exp === 0xff) return sign | 0x7c00 | (mant ? 0x200 : 0);
const e = exp - 127 + 15;
if (e >= 31) return sign | 0x7c00;
if (e <= 0) return e < -10 ? sign : sign | (((mant | 0x800000) >>> (1 - e)) >>> 13);
return sign | (e << 10) | (mant >>> 13);
}
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/postfx-tonemap.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 sampler = device.createSampler({ magFilter: 'nearest', minFilter: 'nearest' });
const uniform = device.createBuffer({ size: 16, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
const pipe = (entry: string): GPURenderPipeline => device.createRenderPipeline({
layout: 'auto',
vertex: { module, entryPoint: 'vs_fullscreen' },
fragment: { module, entryPoint: entry, targets: [{ format: 'rgba8unorm' }] },
primitive: { topology: 'triangle-list' },
});
const tonemapPipe = pipe('fs_tonemap');
const fxaaPipe = pipe('fs_fxaa');
const outTex = (w: number, h: number): GPUTexture => device.createTexture({
size: [w, h], format: 'rgba8unorm',
usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.RENDER_ATTACHMENT
| GPUTextureUsage.COPY_DST | GPUTextureUsage.COPY_SRC,
});
const render = (p: GPURenderPipeline, bg: GPUBindGroup, target: GPUTexture): void => {
const enc = device.createCommandEncoder();
const pass = enc.beginRenderPass({ colorAttachments: [{
view: target.createView(), loadOp: 'clear', storeOp: 'store', clearValue: { r: 0, g: 0, b: 0, a: 1 },
}] });
pass.setPipeline(p);
pass.setBindGroup(0, bg);
pass.draw(3);
pass.end();
device.queue.submit([enc.finish()]);
};
const readPixel = async (tex: GPUTexture, w: number, h: number, px: number, py: number): Promise<number[]> => {
const rb = device.createBuffer({ size: BPR * h, usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ });
const enc = device.createCommandEncoder();
enc.copyTextureToBuffer({ texture: tex }, { buffer: rb, bytesPerRow: BPR }, [w, h]);
device.queue.submit([enc.finish()]);
await rb.mapAsync(GPUMapMode.READ);
const px8 = new Uint8Array(rb.getMappedRange().slice(0));
rb.unmap();
const o = py * BPR + px * 4;
return Array.from(px8.slice(o, o + 3));
};
// --- (1)(2) 色调映射 ---
const hdrTex = device.createTexture({
size: [W, H], format: 'rgba16float',
usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST,
});
const hdrData = new Uint16Array(W * H * 4);
for (let i = 0; i < W * H; i++) {
hdrData[i * 4 + 0] = f32ToF16(HDR[0]);
hdrData[i * 4 + 1] = f32ToF16(HDR[1]);
hdrData[i * 4 + 2] = f32ToF16(HDR[2]);
hdrData[i * 4 + 3] = f32ToF16(1.0);
}
device.queue.writeTexture({ texture: hdrTex }, hdrData, { bytesPerRow: W * 8 }, [W, H]);
const tonemapBg = (input: GPUTexture): GPUBindGroup => device.createBindGroup({
layout: tonemapPipe.getBindGroupLayout(0),
entries: [
{ binding: 0, resource: input.createView() },
{ binding: 1, resource: sampler },
{ binding: 2, resource: { buffer: uniform } },
],
});
device.queue.writeBuffer(uniform, 0, new Float32Array([1.0, 1.0, GAMMA, 0]));
const tmA = outTex(W, H);
render(tonemapPipe, tonemapBg(hdrTex), tmA);
device.queue.writeBuffer(uniform, 0, new Float32Array([2.0, 1.0, GAMMA, 0]));
const tmB = outTex(W, H);
render(tonemapPipe, tonemapBg(hdrTex), tmB);
await device.queue.onSubmittedWorkDone();
const gotA = await readPixel(tmA, W, H, 2, 2);
const gotB = await readPixel(tmB, W, H, 2, 2);
const expA = HDR.map((c) => Math.round(gammaEncode(aces(c), 2.2) * 255));
data.aces = gotA; data.expAces = expA; data.exposure2 = gotB;
const close = (a: number[], b: number[], tol = 2): boolean => a.every((x, i) => Math.abs(x - b[i]!) <= tol);
push('ACES 色调映射与 CPU 一致', close(gotA, expA), `${gotA} vs ${expA}`);
push('曝光 x2 变亮', gotB.every((x, i) => x >= gotA[i]!), `${gotA} -> ${gotB}`);
// --- (3) FXAA ---
const edge = outTex(EW, EH);
const edgeData = new Uint8Array(EW * EH * 4);
for (let y = 0; y < EH; y++) {
for (let x = 0; x < EW; x++) {
const v = x < EW / 2 ? 0 : 255;
const o = (y * EW + x) * 4;
edgeData[o] = edgeData[o + 1] = edgeData[o + 2] = v;
edgeData[o + 3] = 255;
}
}
device.queue.writeTexture({ texture: edge }, edgeData, { bytesPerRow: EW * 4 }, [EW, EH]);
const fxaaBg = device.createBindGroup({
layout: fxaaPipe.getBindGroupLayout(0),
entries: [
{ binding: 0, resource: edge.createView() },
{ binding: 1, resource: sampler },
],
});
const fxaaOut = outTex(EW, EH);
render(fxaaPipe, fxaaBg, fxaaOut);
await device.queue.onSubmittedWorkDone();
const onEdge = await readPixel(fxaaOut, EW, EH, 4, 4);
const flat = await readPixel(fxaaOut, EW, EH, 0, 0);
data.fxaaEdge = onEdge; data.fxaaFlat = flat;
push('FXAA 边界被混合(非 0 非 255)', onEdge[0]! > 0 && onEdge[0]! < 255, onEdge[0]);
push('FXAA 平坦区不变', flat[0] === 0, flat[0]);
device.destroy();
return { checks, data };
}
在 headless Chromium 里跑,本机实测:
PASS wgsl 0 error 0
PASS ACES 色调映射与 CPU 一致 245,231,205 vs 245,231,205
PASS 曝光 x2 变亮 245,231,205 -> 252,245,231
PASS FXAA 边界被混合(非 0 非 255) 223
PASS FXAA 平坦区不变 0
HDR 源 (2,1,0.5) 经 ACES + 伽马后,GPU 回读是 (245,231,205),与 CPU 的 aces()+gammaEncode() 逐字节一致——这条「CPU 算子与 GPU 算子必须给出同样结果」的约束被守住了。曝光翻倍后变亮到 (252,245,231),符合预期。FXAA 那边,正落在黑白边界上的像素被混成 223(既不是 0 也不是 255),而角落的平坦像素原样是 0——说明边缘检测确实只动了该动的地方。
(本节所有数字均来自本机实跑,运行环境见附录 C;src/render/postfx/tonemap-l2.ts 需在浏览器中运行,命令行不可复现。)链能跑、算子对了,但每多一个 pass 就多一份显存流量,15.3 把这笔账算清楚。
阅读导航:上一节:15.1 全屏 Pass 链 · 下一节:15.3 后处理链的性能预算 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。