渲染
颜色空间与色调映射
直觉问题:为什么显示器显示的颜色和真实世界不一样?
Q1: 为什么同一张照片在不同显示器上颜色差异很大?
Q2: 为什么游戏中太阳那么亮,但不会让整个屏幕过曝?
核心概念白话讲
颜色空间
颜色空间定义了如何表示颜色,包含两个维度:
- 色域(Gamut):能显示的颜色范围
- 传递函数(Transfer Function):亮度与数值的映射关系
常见颜色空间对比
| 颜色空间 | 色域覆盖率 | Gamma 曲线 | 应用场景 |
|---|---|---|---|
| sRGB | ~35% CIE 1931 | 2.2 分段线性 | Web、显示器、普通游戏 |
| Rec.709 | ~35% CIE 1931 | 2.2 线性 | HDTV、Blu-ray |
| Rec.2020 | ~75.8% CIE 1931 | PQ/HLG HDR | UHDTV、HDR 视频流 |
| DCI-P3 | ~42% CIE 1931 | 2.6 | 电影放映、Apple 设备 |
色调映射算法对比
| 算法 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| Reinhard | 简单、平滑 | 高光压缩过度,丢失细节 | 简单场景、移动端 |
| Filmic | 保留高光细节 | 实现复杂 | AAA 游戏、电影感渲染 |
| ACES | 电影标准、真实 | 计算量大 | 电影、高质量渲染 |
| Exposure | 可控曝光 | 无自动压缩 | 摄影类应用 |
TIP
Rec.2020 是目前最广的色域标准,覆盖人眼可见颜色的 75.8%,但需要 HDR 显示器支持。
LDR vs HDR
LDR(Low Dynamic Range):8 位整数,范围 [0, 255],动态比 ~256:1
HDR(High Dynamic Range):16/32 位浮点数,范围 [0, +∞),动态比 >10⁶:1
问题:LDR 无法同时表现太阳的亮度和阴影的细节。
Gamma 校正
显示器是非线性的:输入电压与亮度呈幂函数关系。
Gamma 2.2 校正:
WARNING
如果不进行 Gamma 校正,中间调会过暗,暗部细节丢失。
线性渲染管线
graph LR
A[输入资源<br/>sRGB 纹理] --> B[线性化<br/>pow(x, 2.2)]
B --> C[线性空间计算<br/>光照/混合/后处理]
C --> D[色调映射<br/>HDR→LDR]
D --> E[Gamma 编码<br/>pow(x, 1/2.2)]
E --> F[输出显示<br/>sRGB 显示器]
关键步骤:
- 线性化:所有纹理从 sRGB 转换到线性空间
- 线性计算:光照、混合、后处理均在线性空间进行
- 色调映射:将 HDR 映射到 LDR
[0, 1] - Gamma 编码:转换回 sRGB 用于显示
色调映射(Tone Mapping)
色调映射将高动态范围的亮度压缩到低动态范围,同时保留细节。
常用算法对比:
| 算法 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| Reinhard | 简单、平滑 | 高光压缩过度,丢失细节 | 简单场景、移动端 |
| Filmic | 保留高光细节 | 实现复杂 | AAA 游戏、电影感渲染 |
| ACES | 电影标准、真实 | 计算量大 | 电影、高质量渲染 |
| Exposure | 可控曝光 | 无自动压缩 | 摄影类应用 |
LUT(Lookup Table,查找表)
LUT 将复杂的颜色变换预计算为查找表,运行时通过纹理采样快速应用。
3D LUT:
- 输入:
(R, G, B)三维坐标 - 输出:变换后的
(R', G', B') - 大小:通常 32×32×32 或 64×64×64
TIP
Unity、Unreal Engine 等引擎使用 LUT 进行实时调色,无需重写 Shader。
原理与数学机制
Gamma 校正公式
sRGB 分段线性函数
逆 Gamma(用于线性化)
Reinhard 色调映射
白点(White Point)控制:
问题:高光被压缩到接近白色,丢失细节。
Filmic Tone Mapping
Unreal Filmic 近似:
ACES Filmic Tone Mapping
ACES 官方曲线(简化版):
TIP
ACES 是电影工业标准,色彩准确,但计算量较大。可使用预计算 LUT 加速。
LUT 采样数学
3D LUT 索引计算:
其中 N 是 LUT 纹理大小(如 32、64)。
三线性插值:
GLSL vs WGSL 代码对照
Gamma 校正与线性渲染管线
GLSL 版本
#version 300 es
precision highp float;
in vec2 v_texcoord;
uniform sampler2D u_color_texture;
uniform float u_exposure;
out vec4 frag_color;
vec3 linearToSRGB(vec3 linearRGB) {
vec3 sRGBLo = linearRGB * 12.92;
vec3 sRGBHi = pow(linearRGB, vec3(1.0 / 2.4)) * 1.055 - 0.055;
vec3 sRGB = mix(sRGBLo, sRGBHi, step(0.0031308, linearRGB));
return sRGB;
}
vec3 sRGBToLinear(vec3 sRGB) {
vec3 linearLo = sRGB / 12.92;
vec3 linearHi = pow((sRGB + 0.055) / 1.055, vec3(2.4));
vec3 linear = mix(linearLo, linearHi, step(0.04045, sRGB));
return linear;
}
vec3 reinhardToneMapping(vec3 hdr) {
return hdr / (hdr + vec3(1.0));
}
vec3 filmicToneMapping(vec3 hdr) {
vec3 x = max(vec3(0.0), hdr - vec3(0.004));
return (x * (6.2 * x + vec3(0.5))) / (x * (6.2 * x + vec3(1.7)) + vec3(0.06));
}
vec3 acesToneMapping(vec3 hdr) {
const float a = 2.51;
const float b = 0.03;
const float c = 2.43;
const float d = 0.59;
const float e = 0.14;
return clamp((hdr * (a * hdr + vec3(b))) / (hdr * (c * hdr + vec3(d)) + vec3(e)), 0.0, 1.0);
}
void main() {
vec4 color = texture(u_color_texture, v_texcoord);
vec3 linear = sRGBToLinear(color.rgb);
vec3 exposed = linear * exp2(u_exposure);
vec3 tonemapped = acesToneMapping(exposed);
vec3 srgb = linearToSRGB(tonemapped);
frag_color = vec4(srgb, color.a);
}
WGSL 版本
struct FragmentInput {
@location(0) texcoord: vec2<f32>,
}
struct FragmentOutput {
@location(0) color: vec4<f32>,
}
@group(0) @binding(0)
var color_texture: texture_2d<f32>;
@group(0) @binding(1)
var color_sampler: sampler;
@group(0) @binding(2)
var<uniform> exposure: f32;
fn linearToSRGB(linearRGB: vec3<f32>) -> vec3<f32> {
let sRGBLo = linearRGB * 12.92;
let sRGBHi = pow(linearRGB, vec3<f32>(1.0 / 2.4)) * 1.055 - 0.055;
let sRGB = mix(sRGBLo, sRGBHi, step(0.0031308, linearRGB));
return sRGB;
}
fn sRGBToLinear(sRGB: vec3<f32>) -> vec3<f32> {
let linearLo = sRGB / 12.92;
let linearHi = pow((sRGB + 0.055) / 1.055, vec3<f32>(2.4));
let linear = mix(linearLo, linearHi, step(0.04045, sRGB));
return linear;
}
fn reinhardToneMapping(hdr: vec3<f32>) -> vec3<f32> {
return hdr / (hdr + vec3<f32>(1.0));
}
fn filmicToneMapping(hdr: vec3<f32>) -> vec3<f32> {
let x = max(vec3<f32>(0.0), hdr - vec3<f32>(0.004));
return (x * (6.2 * x + vec3<f32>(0.5))) / (x * (6.2 * x + vec3<f32>(1.7)) + vec3<f32>(0.06));
}
fn acesToneMapping(hdr: vec3<f32>) -> vec3<f32> {
let a: f32 = 2.51;
let b: f32 = 0.03;
let c: f32 = 2.43;
let d: f32 = 0.59;
let e: f32 = 0.14;
return clamp((hdr * (a * hdr + vec3<f32>(b))) / (hdr * (c * hdr + vec3<f32>(d)) + vec3<f32>(e)), vec3<f32>(0.0), vec3<f32>(1.0));
}
@fragment
fn fs_main(input: FragmentInput) -> FragmentOutput {
var output: FragmentOutput;
let color = textureSample(color_texture, color_sampler, input.texcoord);
let linear = sRGBToLinear(color.rgb);
let exposed = linear * exp2(exposure);
let tonemapped = acesToneMapping(exposed);
let srgb = linearToSRGB(tonemapped);
output.color = vec4<f32>(srgb, color.a);
return output;
}
差异点:
- WGSL 的
mix()函数参数顺序与 GLSL 相同 pow()函数 WGSL 需要显式类型标注- WGSL 的
clamp()使用vec3<f32>类型
LUT 调色实现
GLSL 版本
#version 300 es
precision highp float;
in vec2 v_texcoord;
uniform sampler2D u_color_texture;
uniform sampler3D u_lut_texture;
uniform float u_lut_intensity;
out vec4 frag_color;
vec3 applyLUT(vec3 color, sampler3D lut, float intensity) {
float lutSize = 32.0;
float scale = (lutSize - 1.0) / lutSize;
float offset = 0.5 / lutSize;
vec3 lutCoord = color * scale + offset;
vec3 lutColor = texture(lut, lutCoord).rgb;
return mix(color, lutColor, intensity);
}
void main() {
vec4 color = texture(u_color_texture, v_texcoord);
vec3 lutColor = applyLUT(color.rgb, u_lut_texture, u_lut_intensity);
frag_color = vec4(lutColor, color.a);
}
WGSL 版本
struct FragmentInput {
@location(0) texcoord: vec2<f32>,
}
struct FragmentOutput {
@location(0) color: vec4<f32>,
}
@group(0) @binding(0)
var color_texture: texture_2d<f32>;
@group(0) @binding(1)
var color_sampler: sampler;
@group(0) @binding(2)
var lut_texture: texture_3d<f32>;
@group(0) @binding(3)
var lut_sampler: sampler;
@group(0) @binding(4)
var<uniform> lut_intensity: f32;
fn applyLUT(color: vec3<f32>, lut: texture_3d<f32>, sampler_type: sampler, intensity: f32) -> vec3<f32> {
let lutSize: f32 = 32.0;
let scale = (lutSize - 1.0) / lutSize;
let offset = 0.5 / lutSize;
let lutCoord = color * scale + offset;
let lutColor = textureSample(lut, sampler_type, lutCoord).rgb;
return mix(color, lutColor, intensity);
}
@fragment
fn fs_main(input: FragmentInput) -> FragmentOutput {
var output: FragmentOutput;
let color = textureSample(color_texture, color_sampler, input.texcoord);
let lutColor = applyLUT(color.rgb, lut_texture, lut_sampler, lut_intensity);
output.color = vec4<f32>(lutColor, color.a);
return output;
}
差异点:
- WGSL 需要显式声明
texture_3d<f32>类型 textureSample()函数替代texture()- 参数传递需要显式类型转换
常见误区与陷阱
-
忘记线性化纹理
- 陷阱:直接使用 sRGB 纹理进行光照计算
- 解决:加载纹理时标记为
SRGB格式,或在 Shader 中手动pow(x, 2.2)
-
色调映射后重复 Gamma 校正
- 陷阱:已经转换为 sRGB 后再次
pow(x, 1/2.2) - 解决:明确管线阶段,避免重复校正
- 陷阱:已经转换为 sRGB 后再次
-
LUT 纹理大小过小
- 陷阱:使用 16×16×16 的 LUT 导致色彩断层
- 解决:使用 32×32×32 或 64×64×64,或使用三线性插值
-
Reinhard 色调映射丢失高光
- 陷阱:高光被压缩到接近白色
- 解决:使用 Filmic 或 ACES,或调整白点参数
-
HDR 纹理格式错误
- 陷阱:使用
RGBA8存储 HDR 纹理导致精度丢失 - 解决:使用
RGBA16F或RGBA32F浮点纹理
- 陷阱:使用
-
ACSE 色调映射参数错误
- 陷阱:使用错误的
a, b, c, d, e参数导致颜色失真 - 解决:使用官方标准值或预计算 LUT
- 陷阱:使用错误的
-
混合颜色空间
- 陷阱:部分纹理在线性空间,部分在 sRGB 空间
- 解决:统一工作流,所有纹理在线性空间计算
-
LUT 强度过高
- 陷阱:
lut_intensity = 1.0导致颜色失真 - 解决:使用
0.5-0.8的中等强度,保留原始色彩
- 陷阱:
延伸阅读与自测
权威资料
- ACES (Academy Color Encoding System) - 电影工业色彩标准
- LearnOpenGL - Gamma Correction - Gamma 校正详细教程
- Khronos - sRGB Extension - OpenGL sRGB 扩展规范
- NVIDIA GPU Gems - Chapter 24 - LUT 加速颜色变换
开源实现参考
- Unity Color Grading - Unity 引擎调色系统
- Unreal Engine Color Grading - UE5 色彩分级
- Three.js EffectComposer - Web 后处理库
自测题
-
思考题: 为什么显示器需要 Gamma 曲线?如果显示器是完全线性的,渲染管线需要做什么改变?
-
对比题: sRGB 与 Rec.2020 在色域覆盖上的区别是什么?为什么 Rec.2020 需要 HDR?
-
实践题: 如何实现一个自适应曝光(Adaptive Exposure)系统,根据场景平均亮度自动调整曝光?
-
扩展题: 为什么 ACES 色调映射比 Reinhard 更适合电影渲染?它们的数学曲线有何本质区别?
-
优化题: LUT 调色在移动端如何优化?是否可以使用 2D LUT 替代 3D LUT?
参考资料获取时间: 2026-07-06,通过 web-search-prime_web_search_prime 工具检索。