前言
本文承接上一篇文章《Unity中Gamma,Linear workflow的一点备忘》,是我对Gamma变换有了一定宏观认识基础上,进一步梳理Unity预设的两种颜色空间工作流(特别是线性工作流)的工作原理,以及它们对渲染管线的影响,最后总结的一些感觉比较有用的Tips。我把经过凝练的一些要点编排成简短的文字放在了本文的开头,以便快速查阅。当然,为了知其所以然,我也把解释说明的内容加在了后面,若有兴趣深入了解,欢迎大家讨论和指正 :)
浓缩要点
Player.Settings -> gamma col space (当设置为gamma流时):
会影响 Unity build-in shader 中的 Unity_ColorSpace_Gamma 宏 -> 从而影响probe,lightmap等自产纹理的读
会影响 RenderTexture 的读写 -> 变成 直读 + 直写
对设备无要求,任何设备都支持 -> 因为是最古老的模式Player.Settings -> linear col space (当设置为linear流时):
会影响 Unity_ColorSpace_Gamma 宏
会影响 RenderTexture 的读写 -> 依据 RT.sRGB (true or flase) -> [true:读时转线性,写时升gamma]; [flase:直读 + 直写]
要求 OpenGL ES 3.0 -> 必须有硬件支持Linear col space(既线性工作流下):
shader输出“颜色”到帧缓存RT上 -> 默认RT.sRGB = true
写入执行 gamma correction
读取执行 remove gamma correction
Blend 操作发生在线性空间,结果存入sRGB空间Linear col space(线性工作流下):
手动重置帧缓存上的RT.sRGB为false -> 需要在生成RT时配置入参 “RenderTextureReadWrite = Linear”
此后该 RT 的读写 -> 变为 直读 + 直写 (仿若在gamma工作流下)纹理面板(texture inspector)
sRGB toggle -> 关联该纹理的 “RenderTextureReadWrite” 属性
本质是影响这张纹理在VRAM里如何被GPU读写Editor工程下,手动创建的 RT
默认 sRGB = false
无法通过面板修改开启HDR,帧缓存RT
RT 的读写 -> 直读 + 直写纹理复制类操作不触发任何颜色空间转换
CopyTexture
ReadPixels
encodeToPNG
...linear工作流注意点
确保目标运行平台支持 (一般都支持)
用户导入的“颜色”纹理需要确保勾选了sRGB项
用户创建的RT的sRGB属性是否设置正确
手动做纹理的Blending或Bliting时,确保前后颜色空间一致
与HDR混用,需注意HDR屏蔽帧缓存RT的sRGB转换功能
sRGB(或Gamma)空间探讨
Editor工程涉及sRGB的设置及其含义
Unity中有关是颜色空间的切换开关位于:Project settings -> Player -> Rendering -> color space
有gamma和linear两种选项:
1)选择gamma时,即为gamma工作流,Unity会默认在gamma空间下对“颜色”和纹理数据进行运算。Unity确保由引擎自身创建并管理的“颜色”和纹理数据(如probe,lightmap,reflection probe等)的采样过程中不会做remove gamma correction处理,保持其原本的sRGB编码特性。同时Unity也会对所有使用到的RenderTexutre进行限制,确保对其的任何读写采样都不会触发由设备支持的额外转换操作。
2)选择linear时,即为linear工作流,Unity会认为“颜色”需要在线性空间下进行运算,因此凡是存储在gamma空间下的各类由引擎自身创建并管理的 “颜色”数据,在读取时会进行额外的remove gamma correction处理,使得返回值提前被换算到线性空间中。这部分功能的实现首先依赖于分布在Unity Shader源码中的宏命令关“UNITY_COLORSPACE_GAMMA”(比如解释光照探针数据时,需要对数据进行解码的逻辑就是依据该宏开启,详细见后文的总结),其次依赖于运行时源码对创建RT的管理,确保所有由引擎创建的“颜色”相关纹理都工作在sRGB模式(同样详见后文)。
当你选择了linear色彩空间,Unity会自动激活RT中的“RenderTextureReadWrite”设置! 从而联动的激活了Editor工程中textrue inspector上的sRGB勾选项(是的,在gamma工作流中,这个勾选项是个摆设!),我们知道该选项关联了系统读取此纹理数据的方式,进而又会影响到Grapics API中对应采样器(sampler)的工作状态。从硬件层面来说,采样器会读取纹理属性(诸如data type,wrap mode, filtering mode,depth comparison等)然后切换工作模式,最后执行采样逻辑。例如当前我们的纹理被设置为sRGB类型,则采样器读取数据时会自动将其转换到linear空间再返回,反之亦然。这部分由硬件支持采样的依据可以参考如下Direct3D文献:(Vulkan和OpenGL也有类似说明文档)
Direct3D 9:
Direct3D 9 can Indicate whether a texture is gamma 2.2 corrected or not (sRGB or not). The driver will either convert to a linear gamma for blending operations at SetTexture time, or the sampler will convert it to linear data at lookup time.(链接)
亦可参考Unity Doc中有关RenderTextureReadWrite设置项的说明
Description
Color space conversion mode of a RenderTexture.
When using Gamma color space, no conversions are done of any kind, and this setting is not used.
When Linear color space is used ... sampling the texture in the shader the sRGB colors are converted into linear values. This is the sRGB read-write mode ...
However, if your render texture will contain non-color data (normals, velocities, other custom values) then you don't want Linear<->sRGB conversions to happen. This is the Linear read-write mode. When this mode is set on a render texture, RenderTexture.sRGB will return false. (链接)
由于有硬件支持的要求,开启Linear工作流会显然是由一定前提的(prerequisite),我们注意到在Unity开源的Editor工程代码中,有如下代码检测了PlayerWindow中颜色空间工作流的合法性,可见对于安卓目标来说,想要正确运行在Linear工作流下,至少需要OpenGLES3及以上的版本。反过来思考,这也从侧面说明了所谓的gamma工作流的本质是对少数没有硬件支持能力的设备的妥协,这也是为什么在gamma流下不论你如何设置纹理采样属性(勾选或不勾选sRGB),渲染管线只会按照“Linear read-write mode”(既直接模式)来读写纹理——设备从物理层面“办不到”!
static bool IsColorSpaceValid(BuildPlatform platform)
{
if (PlayerSettings.colorSpace == ColorSpace.Linear)
{
var hasMinGraphicsAPI = true;
var apis = PlayerSettings.GetGraphicsAPIs(platform.defaultTarget);
if (platform.namedBuildTarget == NamedBuildTarget.Android)
{
hasMinGraphicsAPI = (apis.Contains(GraphicsDeviceType.Vulkan) || apis.Contains(GraphicsDeviceType.OpenGLES3)) && !apis.Contains(GraphicsDeviceType.OpenGLES2);
}
else if (platform.namedBuildTarget == NamedBuildTarget.iOS || platform.namedBuildTarget == NamedBuildTarget.tvOS)
{
hasMinGraphicsAPI = !apis.Contains(GraphicsDeviceType.OpenGLES3) && !apis.Contains(GraphicsDeviceType.OpenGLES2);
}
else if (platform.namedBuildTarget == NamedBuildTarget.WebGL)
{
// must have OpenGLES3-only
hasMinGraphicsAPI = apis.Contains(GraphicsDeviceType.OpenGLES3) && !apis.Contains(GraphicsDeviceType.OpenGLES2);
}
return hasMinGraphicsAPI;
}
else
{
return true;
}
}
Runtime中的sRGB工作流
从问题出发:
1)在渲染过程中,那些由shader输出并临时存放在RenderTexture上的“颜色”处于什么空间?
2)原“颜色”与目标“颜色”进行混合(blending)时,会涉及更新目标RT上的颜色数据,其上对应的颜色存储类型又是什么,在混合时有无特殊处理?
首先第一个问题,在Gamma工作流下答案很明确:RenderTexture上存放的是由shader直接生成的原始数据,一般来说就是sRGB空间下的“颜色”。而在Linear工作流下,我们必须首先确认目标RenderTexture是否具有sRGB属性,如果是,那么任何shader输出的“颜色”(不论是在线性还是gamma空间下计算的)在存入纹理前都会由硬件提供一次gamma correction编码服务,而后再执行写入操作。反之则不做这个gamma correction处理,直接写入。RT的sRGB属性很重要,但是很遗憾目前在Unity Editor工程下手动(非脚本)创建的RenderTexture对象无法设置sRGB属性,其默认值总是false(也就是非sRGB存储格式),而C#脚本创建或者修改的RT可以方便设置其上的sRGB属性,对应的设置参数是前文提到的 “ RenderTextureReadWrite”,这点是潜在的踩坑点,务必管理好用那些由户创建的RT属性和格式,避免因为颜色空间转换不一致而造成的色彩差异问题。
参考Unity Doc中关于 RenderTexture.sRGB属性的说明文档:
Description
Does this render texture use sRGB read/write conversions? (Read Only).
When Linear color space is used, render textures can perform Linear to sRGB conversions when rendering into them and sRGB to Linear conversions when sampling them in the shaders.
The value of this property is based on the "readWrite" parameter of the RenderTexture constructor. (链接)
对于第二个问题,在Linear工作流下,首先我们的shader计算是在线性空间下的,而管线frame buffer管理的RT显然是用于存放sRGB“色彩的”,而混合“颜色”应当属于shader计算的一步,理应发生在线性空间下,因此可以合理推断:targetRT具有sRGB属性,因此存入的线性颜色会被编码到gamma空间,而混合颜色时,会先采样目标纹理上的颜色,此时返回值已处于线性空间,而后拿同样处于线性空间的源颜色一起进行混合(Blending)操作,所得结果再存回目标纹理,此时又编码到了sRGB格式。事实上硬件对Blending操作也有类似的专门支持,可参考如下OpenGL EXT文档:
OpenGL 1.1 framebuffer_srgb
This extension adds a framebuffer capability for sRGB framebuffer update and blending. When blending is disabled but the new sRGB updated mode is enabled (assume the framebuffer supports the capability), high-precision linear color component values for red, green, and blue generated by fragment coloring are encoded for sRGB prior to being written into the framebuffer. When blending is enabled along with the new sRGB update mode, red, green, and blue framebuffer color components are treated as sRGB values that are converted to linear color values, blended with the high-precision color values generated by fragment coloring, and then the blend result is encoded for sRGB just prior to being written into the framebuffer. (链接)
OpenGL1.1 texture_srgb
This extension adds a few new uncompressed and compressed color texture formats with sRGB color components. (链接)
一个关键例外:所有HDR格式(此外还有Depth和Shadowmap等格式)的纹理(共同特定是存储数值类型是floating point),不论其sRGB属性如何标记,对Unity来说都是以“Linear read-write mode”方式读写,换言之,存放在RT中的数据是线性空间下的HDR浮点数据,直接读写,所有涉及颜色空间的操作留在了后处理阶段的tonemapping pass上,这个后面的文章会讲。
参考Unity Doc中有关RenderTextureReadWrite设置项的说明
Note that some render texture formats are always considered to contain "linear" data and no sRGB conversions are ever performed on them, no matter what is the read-write setting. This is true for all "HDR" (floating point) formats, and other formats like Depth or Shadowmap. (链接)
Unity shader部分
已知Unity的自带shader中,有一套处理颜色空间转换的代码和宏,转换逻辑定义在UnityCG.cginc (以std shader 2019版为例,urp类似),主要方法包括:
bool IsGammaSpace() //遗产方法,判断当前所处颜色空间
float GammaToLinearSpaceExact (float value) //使用指数乘法方式remove gamma correction
half3 GammaToLinearSpace (half3 sRGB) //近似求解方案,只用3个mad指令 <--主要使用这个
float LinearToGammaSpaceExact (float value) //使用指数乘法方式添加gamma correction
half3 LinearToGammaSpace (half3 linRGB) //同为近似求解方案
宏定义为:
UNITY_NO_LINEAR_COLORSPACE //5.4版本后移除
UNITY_COLORSPACE_GAMMA //当前版本使用这个宏
当开启或关闭 “UNITY_COLORSPACE_GAMMA”这个宏时,会影响到Unity shader的如下方面:
- 采样 Light Probe 这种由Unity自己创建和管理的“颜色”,
- 需要Unity动态计算一些特殊纹理,如Skybox,计算过程会受所处颜色空间影响,
- 解码HDR纹理时,这类纹理的采样值一般特殊编码过,Unity会在合适的时机对采样结果进行解码 + 色彩转换,
- Unity内建BRDF的部分运算参数会在不同颜色空间下做不同修正,
- Unity内建的VideoDecode着色器中,会对采样颜色依据色彩空间做转换修正。
一些Tips:
- GPU内对RT的采样和写入是可能会产生颜色空间转换操作的,但是对纹理的复制(CopyTextrue,ReadPixels,encodeToPNG等)不会触发此类操作。可以简单通过以下实验验证:
任何渲染管线的Linear工作流下,关闭HDR,将shader输出存放到两张RT中,一张读写模式为sRGB,一张为Linear,而后通过ReadPixels的方式将纹理数据复制到CPU端RAM里,最后通过encodeToPNG的方式存入本地磁盘。
为了方便观察变化,实验输出纹理是由shader在PS阶段直接计算生成的,方法是按uv轴的u方向从左到右数值线性增大,形成灰阶图,生成逻辑参考如下frag代码:
half4 frag(VertexOutput IN) : SV_Target{
int base = IN.uv.x * 255;
half col = half(base) / 255.0;
return half4(col.xxx, 1);
}
下面是摄像机输出图以及两种类型的生成图(中间的黑线用于辅助观察):
表格中最后一行是在保持为PNG前,输出的内存中图像中心点像素信息:
Color p = screen.GetPixel(width/2, height/2); //选取中间坐标点输出沿着
Debug.Log($"center col = {p}");
解读:Shader输出的中点信息应当是0.5,写入到sRGB帧缓存后会被硬件附加pow(0.5,1/2.2) 操作,变为0.733;写入到Linear帧缓存则什么额外操作都不会发生,仍然是0.5。刚才说了,纹理拷贝是直接拷贝,这从在CPU端打印中心点像素信息可以看出,sRGB与Linear对应的返回值符合预期,与其在RT中存放的数值一致。
不得不说线性的灰阶纹理直接在显示器上输出的观感更加“自然和谐”,看起来分布得更加“匀称”,然而这只是视觉感官上的认知罢了,正确的颜色来自于上图中存放在sRGB空间的纹理(左和中),经过显示器调节后被我们看到的图像才是灰阶纹理原本的样子。
诸如NormalTexture,ShadowMap,Depth,Velocities和一些其他用户生成的非“颜色”纹理,不应该存放在sRGB模式的RT中。
在Linear工作流下,如果一张用户创建的RT只在GPU内部临时存在,且颜色生成方和颜色获取方都是做颜色计算的shader,那么不勾选sRGB可以减少两次色彩空间转换操作,虽然这也节省不了什么。
选gamma流的绝大多数情况是因为目标平台有限制,在这个工作流下,使用者只需要注意导入的“颜色”纹理本身是正确存储在sRGB空间的就行,而挑战主要在于颜色运算被放到了gamma空间下,很多原本被设计在线性空间下运行的shader会出问题。
在linear工作流中(绝大多数工程会选这个),首先要确保目标运行平台是否支持该工作流特性(OpenGL ES3.0),其次用户导入的“颜色”纹理需要确保勾选了sRGB项,同时也需要时刻注意用户创建的RT的sRGB属性是否设置正确,最后我们在手动做纹理的Blending或Bliting时,需要确保前后使用者所处颜色空间的一致性。
总结
至此希望大家都能明晰在不同设置条件下,每个阶段的“颜色”都处于什么样的空间,搞清楚这点对我们避免颜色空间混淆大有益处,特别是后续还会追加的HDR模式,它也会给颜色纹理的处理带来新的变化,详细的内容是后话了。