广色域渲染
第二章「色彩管理」介绍了 ColorSpace API 的使用方式。本节深入解析 TGFX 内部如何实现色彩空间转换——从转换步骤的计算与优化,到 GPU Shader 代码的动态生成,再到 CPU 端批量像素转换,以及 SVG / PDF 导出时的色彩空间嵌入策略。
色域范围对比
不同色彩空间覆盖的可见光范围差异显著。以 CIE 1931 xy 色度图为参考,四种常用色彩空间的色域覆盖如下:

| 色彩空间 | 色域基色 | 相对 sRGB 面积 | 典型应用 |
|---|---|---|---|
| sRGB / Rec.709 | R(0.64, 0.33) G(0.30, 0.60) B(0.15, 0.06) | 100% | Web、标准显示器 |
| Display P3 (D65) | R(0.680, 0.320) G(0.265, 0.690) B(0.150, 0.060) | ~125% | Apple 设备、HDR 内容 |
| Adobe RGB | R(0.64, 0.33) G(0.21, 0.71) B(0.15, 0.06) | ~115% | 印刷、专业摄影 |
| Rec.2020 | R(0.708, 0.292) G(0.170, 0.797) B(0.131, 0.046) | ~176% | UHD/HDR 视频 |
Display P3 在红色和绿色方向上比 sRGB 扩展了约 25% 的色域面积,能表达更鲜艳的红色和更饱和的绿色。Adobe RGB 则主要在青绿色区域扩展。Rec.2020 覆盖了人眼可见色域的绝大部分,是 HDR 视频的标准色彩空间。
以下是通过 SVG 定义的多种广色域颜色(Display P3、Rec.2020 等),渲染到 Display P3 Surface 上的实际效果:

上排为 Display P3 色域的绿色、红色、蓝色,下排为其他广色域颜色。在支持广色域的显示器上,这些色块比标准 sRGB 颜色更加鲜艳饱和。
TGFX 在 ColorSpace.h 中以 ColorSpacePrimaries 结构定义了上述所有色域基色的精确坐标(NamedPrimaries::Rec709、NamedPrimaries::SMPTE_EG_432_1、NamedPrimaries::Rec2020 等),并通过 ColorSpacePrimaries::toXYZD50() 方法将色域基色转换为 CIE XYZ D50 矩阵——这是所有色域变换的中间参考空间。
ColorSpaceXformSteps:转换步骤计算
ColorSpaceXformSteps(位于 src/core/ColorSpaceXformSteps.h)是色彩空间转换的核心数据结构。它在构造时根据源和目标色彩空间的属性,计算出一个最优的转换管线。
完整管线
一次色彩空间转换最多包含 7 个步骤:
源像素 (编码值)
│
▼
① Unpremultiply ── 解除 alpha 预乘(仅当源为 Premultiplied 时)
│
▼
② Linearize ────── 应用源传输函数(编码值 → 线性光值)
│
▼
③ Source OOTF ──── 应用源光-光传输函数(仅 HLG 等 HDR 传输函数)
│
▼
④ Gamut Transform ─ 3×3 色域矩阵变换(源 XYZ → 目标 XYZ)
│
▼
⑤ Dest OOTF ────── 应用目标光-光传输函数的逆(仅 HLG 等 HDR 传输函数)
│
▼
⑥ Encode ────────── 应用目标传输函数的逆(线性光值 → 编码值)
│
▼
⑦ Premultiply ──── 重新 alpha 预乘(仅当目标为 Premultiplied 时)
│
▼
目标像素 (编码值)
其中 OOTF(Opto-Optical Transfer Function)是 HLG 等 HDR 传输函数特有的步骤,用于在不同亮度的显示设备间进行场景参考与显示参考之间的映射。对于 sRGB 和 Display P3 等 SDR 色彩空间,OOTF 步骤不会被启用。
传输函数的类型化处理
构造函数根据 skcms_TransferFunction_getType() 判断源/目标传输函数的类型,分三种情况处理:
| 传输函数类型 | 典型代表 | 处理方式 |
|---|---|---|
| sRGBish | sRGB、Rec.709、2.2 gamma | 直接使用 ICC 7 参数传输函数 |
| PQish | Rec.2100 PQ (HDR) | 使用专用 PQ 传输函数,按 10000 nit 峰值亮度缩放 |
| HLGish | Rec.2100 HLG (HDR) | 使用专用 HLG 传输函数,按峰值/参考白亮度缩放,可能启用 OOTF |
自动优化:跳过冗余步骤
构造函数的关键设计是自动剪枝——跳过不必要的步骤以提高性能:
相同色彩空间:
srcHash == dstHash && srcAT == dstAT时直接返回,所有 flags 保持 false,不执行任何操作相同传输函数:如果源和目标的
transferFunctionHash相同,Linearize 和 Encode 步骤互相抵消,两个 flag 都设为 false。这意味着 sRGB → Display P3 转换只需要做色域矩阵变换,无需 gamma 编解码OOTF 互消:如果源和目标都有 OOTF、且没有色域变换、且 gamma 互逆(
(srcGamma+1) × (dstGamma+1) == 1),两个 OOTF 步骤互相抵消Premul 跳过:如果在 Unpremul 和 Premul 之间没有非线性操作(既不需要 Linearize 也不需要 Encode),那么 Unpremul-Premul 对可以整体跳过
这些优化在构造时一次性完成,后续无论是 GPU Shader 代码生成还是 CPU 逐像素计算,都只执行真正需要的步骤。
色域矩阵计算
色域变换的核心是一个 3×3 矩阵 srcToDstMatrix。计算过程:
- 源色彩空间通过
toXYZD50()得到srcToXYZ矩阵 - 目标色彩空间通过
toXYZD50()得到dstToXYZ矩阵,再求逆得到XYZtoDst - 两者相乘得到
srcToDst = XYZtoDst × srcToXYZ
GPU 端:Shader 代码动态生成
GPU 端的色彩空间转换通过 ColorSpaceXformEffect(Fragment Processor)和 ColorSpaceXformHelper(Shader 代码生成辅助类)实现。整个过程是一套"元编程"系统——根据 ColorSpaceXformSteps 的 flags 动态生成 GLSL/MSL Shader 代码。
架构分层
ColorSpaceXformEffect (FragmentProcessor)
│
├── emitCode() → 生成 Shader 代码
│ │
│ ▼
│ ColorSpaceXformHelper.emitCode()
│ │
│ ▼
│ ShaderBuilder.appendColorGamutXform()
│ │
│ ├── emitTFFunc() → 生成传输函数 float src_tf(float x) { ... }
│ ├── emitOOTFFunc() → 生成 OOTF 函数 vec3 src_ootf(vec3 color) { ... }
│ ├── gamut_xform() → 生成色域变换 color.rgb = ColorXform * color.rgb
│ └── color_xform() → 组装完整转换 vec4 color_xform(vec4 color) { ... }
│
├── onSetData() → 上传 Uniform 数据
│ │
│ ▼
│ ColorSpaceXformHelper.setData()
│ │
│ ├── SrcTF0/SrcTF1 → float4 × 2(传输函数 7 参数)
│ ├── SrcOOTF → float4(OOTF Y 系数 + gamma)
│ ├── ColorXform → float3x3(色域矩阵)
│ ├── DstOOTF → float4
│ └── DstTF0/DstTF1 → float4 × 2
│
└── onComputeProcessorKey() → 生成缓存 Key
Shader 代码生成细节
ShaderBuilder::appendColorGamutXform() 是代码生成的核心。它根据 ColorSpaceXformHelper 的 flags 按需生成辅助函数并组装成一个完整的 color_xform() 函数:
传输函数生成(emitTFFunc):根据传输函数类型生成不同的数学公式——
- sRGBish:分段函数
x < D ? C*x + F : pow(A*x + B, G) + E - PQish:
pow(max(A + B*pow(x, C), 0) / (D + E*pow(x, C)), F) - HLGish:
x*A <= 1 ? pow(x*A, B) : exp((x - E)*C) + D - HLGinvish:
x <= 1 ? A*pow(x, B) : C*log(x - D) + E
所有函数都处理了负值(取绝对值计算后恢复符号),确保广色域中可能出现的超出 [0,1] 的值不会产生 NaN。
色域变换生成(gamut_xform):生成一个简单的 3×3 矩阵乘法 color.rgb = ColorXform * color.rgb,矩阵值通过 Uniform float3x3 传入。
完整转换函数(color_xform):按顺序组装所有启用的步骤。代码结构如下(伪代码):
vec4 color_xform(vec4 color) {
// ① Unpremultiply(仅当 flags.unPremul 为 true 时生成)
float alpha = color.a;
color = alpha > 0.0 ? vec4(color.rgb / alpha, alpha) : vec4(0.0);
// ② Linearize(仅当 flags.linearize 为 true 时生成)
color.r = src_tf(color.r);
color.g = src_tf(color.g);
color.b = src_tf(color.b);
// ③ Source OOTF(仅当 flags.srcOOTF 为 true 时生成)
color.rgb = src_ootf(color.rgb);
// ④ Gamut Transform(仅当 flags.gamutTransform 为 true 时生成)
color = gamut_xform(color);
// ⑤ Dest OOTF(仅当 flags.dstOOTF 为 true 时生成)
color.rgb = dst_ootf(color.rgb);
// ⑥ Encode(仅当 flags.encode 为 true 时生成)
color.r = dst_tf(color.r);
color.g = dst_tf(color.g);
color.b = dst_tf(color.b);
// ⑦ Premultiply(仅当 flags.premul 为 true 时生成)
color.rgb *= color.a;
return color;
}
由于 Shader 代码是动态生成的,跳过的步骤不会出现在最终的 Shader 中,因此没有分支开销。例如 sRGB → Display P3 转换只会生成 Unpremul → GamutXform → Premul 三步(传输函数相同被跳过)。
Shader 缓存与 Key 机制
ColorSpaceXformSteps::XFormKey() 将转换步骤编码为一个 32-bit 整数:低 8 位是 flags mask(哪些步骤启用),第 8-15 位编码源传输函数类型,第 16-23 位编码目标传输函数类型。这确保了相同转换步骤的 Shader 可以被 GPU Pipeline 缓存系统复用。
触发转换的入口
GPU 端色彩空间转换通过 ColorSpaceXformEffect 这个 FragmentProcessor 注入到渲染管线。注入点有三类:
Image 绘制(
ImageShader::asFragmentProcessor):当 Image 的色彩空间与 Surface 的色彩空间不同时,在纹理采样的 FragmentProcessor 之后 Compose 一个ColorSpaceXformEffectDrawImage 直接绘制(
OpsCompositor):与 ImageShader 路径类似,但发生在 OpsCompositor 的 DrawOp 构建流程中RuntimeEffect 输入纹理对齐(
RuntimeDrawTask::GetFlatTextureView):当多个输入纹理的色彩空间不一致时,在扁平化纹理的过程中插入色彩空间转换,确保传入onDraw()的所有纹理处于同一色彩空间顶点颜色转换(
RectsVertexProvider):当矩形绘制带有顶点颜色且目标色彩空间非 sRGB 时,通过 CPU 端ColorSpaceXformSteps::apply()在写入顶点缓冲区时直接转换颜色值,避免在 Shader 中额外处理渐变色转换(
GradientShader):渐变色在 CPU 端通过ColorSpaceXformSteps::apply()将 sRGB 颜色值预转换到目标色彩空间,然后直接写入 Uniform 数据
CPU 端:skcms 批量像素转换
CPU 端的色彩空间转换分为两条路径:
逐像素转换
ColorSpaceXformSteps::apply(float rgba[4]) 方法在 CPU 上对单个像素执行与 GPU Shader 完全相同的转换管线。它直接调用 skcms_TransferFunction_eval() 计算传输函数、用 3×3 矩阵做色域变换。这条路径用于少量颜色的转换,例如渐变色预处理和顶点颜色转换。
批量行级转换
CopyPixels()(位于 src/core/utils/CopyPixels.cpp)是 CPU 端批量像素转换的入口。它将源和目标色彩空间转换为 skcms_ICCProfile,然后逐行调用 skcms_Transform():
auto srcProfile = ToSkcmsICCProfile(srcColorSpace);
auto dstProfile = ToSkcmsICCProfile(dstColorSpace);
for (int i = 0; i < height; i++) {
skcms_Transform(srcPixels, srcFormat, srcAlpha, &srcProfile,
dstPixels, dstFormat, dstAlpha, &dstProfile,
width);
// 移动到下一行...
}
skcms_Transform() 是 Google 的 skcms 库 提供的高性能色彩转换函数,内部针对 x86 SSE/AVX 和 ARM NEON 做了 SIMD 优化。它同时处理色彩空间转换和像素格式转换(如 RGBA_8888 → RGBA_F16),避免了两次遍历。
ConvertColorSpaceInPlace() 是就地转换的便捷封装:
void ConvertColorSpaceInPlace(int width, int height, ColorType colorType,
AlphaType alphaType, size_t rowBytes,
const shared_ptr<ColorSpace>& srcCS,
const shared_ptr<ColorSpace>& dstCS,
void* pixels) {
auto srcInfo = ImageInfo::Make(width, height, colorType, alphaType, rowBytes, srcCS);
auto dstInfo = srcInfo.makeColorSpace(dstCS);
CopyPixels(srcInfo, pixels, dstInfo, pixels); // 源和目标指向同一内存
}
这条路径的典型使用场景包括:各平台 NativeCodec 解码后的色彩空间转换、Surface::readPixels() 的像素回读、以及 Web 平台 Canvas 光栅化结果的后处理。
NeedConvertColorSpace:转换判定
NeedConvertColorSpace() 是贯穿整个系统的判定函数,决定是否需要进行色彩空间转换:
bool NeedConvertColorSpace(const shared_ptr<ColorSpace>& src,
const shared_ptr<ColorSpace>& dst) {
if (dst == nullptr) return false;
ColorSpace* newSrc = src ? src.get() : ColorSpace::SRGB().get();
return !ColorSpace::Equals(newSrc, dst.get());
}
设计要点:
- 目标为 nullptr 时不转换——这是「不关心色彩空间」的语义
- 源为 nullptr 时默认按 sRGB 处理——这确保了未标注色彩空间的内容在广色域 Surface 上也能正确显示
ColorSpace::Equals()使用 hash 快速判等,避免逐字段比较
SVG 导出中的色彩空间处理
SVG 导出时的色彩空间处理通过 targetColorSpace 和 assignColorSpace 两个参数控制,分别对应"转换"和"指派"两种语义:
- targetColorSpace:将所有颜色值从 sRGB 转换到目标色彩空间后写入 SVG。这会改变颜色的数值
- assignColorSpace:不转换颜色值,仅在像素数据中嵌入指定的色彩空间标记
颜色值转换
ElementWriter 在写入 SVG 元素时,对所有颜色值(fill、stroke、阴影颜色、渐变色等)调用 ConvertColorSpace() 将 sRGB 颜色转换到目标色彩空间。渐变色则通过 ColorSpaceXformSteps::apply() 在 CPU 端逐色标转换。
图像像素转换
图像数据通过 ConvertImageColorSpace() 函数处理:先创建一个目标色彩空间的 Surface,将图像绘制上去(GPU 自动完成色彩空间转换),再 readPixels() 回读转换后的像素数据,最终编码为 PNG 嵌入 SVG。
PDF 导出中的色彩空间嵌入
PDF 对色彩空间有更严格的规范要求。TGFX 的 PDF 导出采用 ICC Profile 嵌入的方式:
ICC Profile 序列化
PDFDocumentImpl::emitColorSpace() 将目标色彩空间导出为 ICC Profile 数据并嵌入 PDF:
┌──────────────────────┐
ColorSpace │ PDFStream │
│ │ /Type /ICCBased │
▼ │ /N 3 │
toICCProfile() ──→ │ /Alternate DeviceRGB│
│ [ICC binary data] │
└──────────────────────┘
│
▼
PDFArray ["ICCBased", ref]
│
▼
ResourceDict /ColorSpace /CS ref
每个页面的资源字典中都引用这个全局的 ICC Profile 对象(/ColorSpace /CS),确保 PDF 阅读器能正确解释所有颜色值。
像素数据处理
PDFBitmap::SerializeImage() 在序列化图像时,创建一个目标色彩空间的 Surface 来绘制源图像,然后以 Unpremultiplied 格式回读像素——PDF 规范要求图像数据为非预乘格式。如果设置了 colorSpaceRef,图像的 ColorSpace 字典会引用该 ICC Profile;否则回退到 DeviceRGB。
渐变色处理
PDFGradientShader 在生成渐变色 PDF 对象时,也会检查 NeedConvertColorSpace(),并通过 ColorSpaceXformSteps::apply() 在 CPU 端将 sRGB 渐变色标转换到目标色彩空间。
与 Skia 的方案对比
| 维度 | TGFX | Skia |
|---|---|---|
| 色彩空间库 | 使用 skcms(Skia 的子项目),CPU 端共用同一底层 | 同样使用 skcms |
| GPU 转换机制 | ColorSpaceXformEffect (FragmentProcessor) 动态生成 GLSL/MSL | GrColorSpaceXformEffect (GrFragmentProcessor),机制类似 |
| Shader 代码生成 | 直接在 ShaderBuilder 中拼接 GLSL 字符串 | 通过 SkSL 跨编译器生成,多一层抽象 |
| 步骤优化 | 构造时一次性剪枝,7 种优化规则 | 类似的构造时优化策略 |
| PDF/SVG 嵌入 | 直接将 ColorSpace 序列化为 ICC Profile 嵌入 | Skia 的 PDF 后端采用类似的 ICC Profile 嵌入方式 |
