Three.js 色彩空间的正确使用方式
three中色彩空间常见用处
// 给材质设置色彩空间
material1.map.colorSpace = THREE.SRGBColorSpace;
// 给渲染器的输出色彩空间, 不设置的话默认值也是SRGBColorSpace
new THREE.WebGLRenderer( { outputColorSpace: THREE.SRGBColorSpace } );
three.js r152+ 之后默认就是
SRGBColorSpace,老版本(outputEncoding时代)行为不同
色彩空间的选项
-
SRGBColorSpace-sRGB 色彩空间
-
LinearSRGBColorSpace-线性sRGB色彩空间
区别?
SRGBColorSpace进行了伽马校正
为什么会有伽马校正?
-
纠正硬件的问题
在液晶显示器普及之前,使用的是笨重的 CRT (阴影 栅格 显像管 ) 电视。CRT 的工作原理是用电子枪射出电子束轰击屏幕,科学家发现,电子枪的电压值和屏幕产生的亮度之间并不是 1:1 的线性关系,而是一个幂函数关系:
也就是如下图中红色曲线所示,跟原本蓝色虚线比较,亮度是偏低的
所以为了还原真实效果,抵消调 CRT压低的亮度,那就把真实亮度数据提高,提高成绿色曲线那样,这样一抵消,显示就正常了,这个提高的过程就是伽马校正
-
也能满足存储空间合理分配
-
人眼特性:我们对暗部的变化非常敏感,而对亮部的变化比较迟钝。
-
数据分配的矛盾:如果我们在电脑里用“线性”方式存储亮度(比如 0 代表黑,128 代表半亮,255 代表全亮):
- 在 0 到 10 之间(暗部),只有 10 个档位。因为我们眼睛太敏感,这 10 个档位之间的跳变看起来会像阶梯一样,非常不自然(这就是“色彩断层”)。
- 在 200 到 250 之间(亮部),虽然有 50 个档位,但我们的眼睛根本分不出这 50 种亮度的区别。这部分昂贵的存储空间(位深)就被浪费了。
-
解决方案(伽马编码) : 故意把 256 个档位中的大部分都分给“暗部”,只留少部分给“亮部”。这样既照顾了人眼的敏感度,又没有浪费存储空间。这样我们可以在 8 位(0-255)的空间里,把更多数值分配给敏感的暗部,让有限的资源发挥最大效用。如图所示(随便找一个以前的暗部区域值,映射后占居的区域明显变多)
为什么现在屏幕“正常”了,还需要它?
现在的液晶(LCD)或 OLED 屏幕完全可以做到“给 128 就亮 50%”,为什么还要折腾?
行业标准的惯性
全球互联网上 99% 的图片(JPEG)、视频(MP4)和网页标准(HTML/CSS)都是基于 sRGB 色彩空间存储的
-
如果显示器突然改为“线性显示”,那么所有的互联网内容看起来都会变得非常亮。
-
并且图片大多还是如图所示8位,需要上面说过的满足存储空间并合理分配
正确色彩空间处理的流程
-
原始图片 默认是 sRGB 色彩空间,它自带一条 “上翘” 的伽马曲线
-
转成 线性空间 :在进入 GPU 运算前,需要先把图片从 sRGB 非线性空间转换为 Linear(线性)sRGB 空间。这一步会把上翘的曲线 “拉平” 成一条直线,让亮度数据恢复成物理上均匀的数值,确保后续的光照、混合等计算结果是准确的。
-
程序运算 :在线性空间里进行渲染计算,比如光影追踪、材质混合、特效合成等。因为线性空间的亮度是均匀的,所以计算出来的光影效果才符合物理规律,不会出现颜色偏差或暗部丢失。
-
渲染结果 :对计算结果,经过伽马校正后,得到的就是最终的 sRGB 格式渲染结果,它的亮度曲线和原始图片的格式是一致的。
-
显示器显示 🖥️显示器接收到 sRGB 信号后,会用它自带的伽马曲线(通常 γ≈2.2)来显示,这个过程会把信号 “压暗”。因为我们已经提前做了伽马校正,所以两次曲线变化刚好抵消,最终显示在屏幕上的亮度就和我们计算的结果完全一致。
-
人眼感知 👀最终画面被人眼看到,色彩和亮度都保持了设计和计算时的真实效果,不会出现过暗或过亮的问题。
总结
也就是我们要保证用来计算的时候是 Linear( 线性空间,用来渲染的时候是sRGB 空间,那在three中如何做到?
Three.js从输入的角度
Three.js中我们只需要指定 色彩空间 类型即可,程序会帮我们转成线性,所以我们要做的就是把应该指定为SRGBColorSpace的纹理,指定为SRGBColorSpace
举几种常见加载器对加载后的图片色彩空间的处理逻辑
TextureLoader
TextureLoader 不设置 colorSpace,保持默认 NoColorSpace,需要手动设置:
注意!颜色纹理需要手动指定色彩空间为SRGBColorSpace,像下文GLTFLoader中的逻辑一样,
例如
const texture = await loader.loadAsync( 'textures/land_ocean_ice_cloud_2048.jpg' );
texture.colorSpace = THREE.SRGBColorSpace;
CubeTextureLoader
CubeTextureLoader 固定设置为 SRGBColorSpace:
GLTFLoader
只有颜色纹理会被设置为 SRGBColorSpace,其他纹理保持 NoColorSpace:
设置为 SRGBColorSpace 的纹理:
-
baseColorTexture (map) → SRGBColorSpace
-
emissiveTexture (emissiveMap) → SRGBColorSpace
-
sheenColorTexture (sheenColorMap) → SRGBColorSpace
-
specularColorTexture (specularColorMap) → SRGBColorSpace
这几种色彩空间标记的处理逻辑
| exture.colorSpace | 内部格式 | sRGB → Linear 转换 |
|---|---|---|
| NoColorSpace(默认) | RGBA8 | 不转换,原样上传 |
| SRGBColorSpace | SRGB8_ALPHA8 | GPU 采样时自动转 SRGB8_ALPHA8 是 WebGL 2.0 的 sRGB 纹理格式 GPU 在采样时自动应用 sRGB EOTF(Electro-Optical Transfer Function)将 sRGB 转为线性 |
| LinearSRGBColorSpace | RGBA8 | 不转换,已是线性 |
也提供了方法可以手动转化,THREE.Color 类下即可调用
src/math/ColorManagement.js
export function SRGBToLinear( c ) {
return ( c < 0.04045 ) ? c * 0.0773993808 : Math.pow( c * 0.9478672986 + 0.0521327014, 2.4 );
}
export function LinearToSRGB( c ) {
return ( c < 0.0031308 ) ? c * 12.92 : 1.055 * ( Math.pow( c, 0.41666 ) ) - 0.055;
}
Three.js中-从输出的角度
threejs会在
- MeshBasicMaterial
- MeshPhysicalMaterial
- MeshPhongMaterial
- MeshLambertMaterial
- MeshToonMaterial
- MeshMatcapMaterial
- SpriteMaterial
- PointsMaterial 等等
材质输出的时候增加这样一段代码
src/renderers/shaders/ShaderChunk/colorspace_fragment.glsl.js
gl_FragColor = linearToOutputTexel( gl_FragColor );
linearToOutputTexel函数会根据outputColorSpace来动态配置
getTexelEncodingFunction( 'linearToOutputTexel', parameters.outputColorSpace ),
说白了就是输出的时候会跟你设置的outputColorSpace来判断需不需要转成SRGBColorSpace,默认是转成SRGBColorSpace
我们自己写的ShaderMaterial输出的时候怎么办
我们也可以调用linearToOutputTexel
因为linearToOutputTexel
-
注入时机:在 WebGLProgram 构造函数中构建 prefixFragment 时
-
注入方式:通过 getTexelEncodingFunction 动态生成函数代码,添加到 prefixFragment
-
可用性:所有非 RawShaderMaterial 的材质(包括 ShaderMaterial)都会自动注入
总结
在 three.js 中,默认的色彩空间配置已经覆盖了大多数使用场景。只要遵循颜色纹理使用 sRGB、渲染计算在 线性空间 、输出再转回 sRGB这一基本原则,画面通常就是正确的。但是理解色彩空间与伽马校正的原理,才能在自定义 Shader、特殊纹理或渲染需求出现时,有意识地手动调整配置,而不是盲目试参数