基于URP搭建Linear色彩空间下的UI渲染管线

基于URP搭建Linear色彩空间下的UI渲染管线

【USparkle专栏】如果你深怀绝技,爱“搞点研究”,乐于分享也博采众长,我们期待你的加入,让智慧的火花碰撞交织,让知识的传递生生不息!


一、前言

自Unity支持Linear色彩空间以来,Unity的3D渲染的光影层次变得更加准确细腻,但是Linear色彩空间下UI的渲染却因此变得糟糕。绝大多数的UI资产都是在sRGB色彩空间里制作完成的,UI图片的格式也都是基于sRGB,所以Linear色彩空间下,基于sRGB的UI图片的Alpha得不到正确的混合,使得UI图片的不透明度得不到正确的呈现。

为了匹配Linear色彩空间下的Alpha混合,有些团队限制使用半透明资产;有些是在Ps里盲调,再到Unity里做验证,直到看起舒服为止;或是在Ps里改变图片色彩空间。这些做法无疑都是限制了UI制作流程。

现在的URP基于可编程渲染管线,支持自定义管线。要想彻底解决UI渲染问题,可以在Unity管线层面,做出合理的管线设计,维持UI设计师正常的sRGB资产制作流程。

二、管线效果对比


左边: Photoshop效果 | 中间: 自定义UI管线效果 | 右边: Unity URP默认效果

三、管线的设计思路

  1. 在原有Linear色彩空间的Buffer里渲染3D图形;
  2. 将渲染完成的3D图像转移至Gamma色彩空间的UI Buffer中;
  3. 在Gamma色彩空间的UI Buffer中继续渲染UI图片;
  4. 将最终的渲染结果转回到Linear,并最终输出。

因为UI图片的Alpha Blend是在Gamma空间下完成的,所以不存在错误的混合结果,兼容了Linear色彩空间的3D渲染和Gamma色彩空间下的UI渲染。

四、管线的具体实现

管线流程
思路有了,那么再结合URP现有的流程,详细方案如下:

3D使用Base的Main Camera渲染,UI使用Overlay的UI Camera渲染,并把UI Camera塞到Main Camera的Stack当中。

URP在使用Post-Processing(后处理)时,本身在3D渲染完就会有一次Uber Post Process的Pass,以及不管有没有后处理,在最终画面渲染完都会有一次Final Blit的Pass(如果开了FXAA,则是Final Post)。只要我们在这两个Pass里做色彩空间转换,几乎不会产生多少额外的性能开销。只有在不使用Post-Processing时,才需要在3D渲染完成时额外补一个Pass做色彩空间转换。

另外,Uber Post Process和Final Blit都会用到"Hidden/Universal Render Pipeline/Blit"这个Shader着色, 因此可以在Blit Shader的片元着色器里加入色彩空间转换的函数和全局的Keyword宏,以方便在管线中利用Command Buffer设置keyword进行色彩空间转换。

Shader("Hidden/Universal Render Pipeline/Blit"):

half4 Fragment(Varyings input) : SV_Target
{
       UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(input);

       half4 col = SAMPLE_TEXTURE2D_X(_SourceTex, sampler_SourceTex, input.uv);

    #ifdef _LINEAR_TO_SRGB_CONVERSION
       col = LinearToSRGB(col);
    #endif

    #ifdef _SRGB_TO_LINEAR_CONVERSION
       col = SRGBToLinear(col);
    #endif

       return col;
}

管线部分(Uber Post Process):

var cmd = CommandBufferPool.Get();
using (new ProfilingScope(cmd, m_ProfilingRenderPostProcessing))
{
    cmd.EnableShaderKeyword(ShaderKeywordStrings.LinearToSRGBConversion);  
    Render(cmd, ref renderingData);               
    cmd.DisableShaderKeyword(ShaderKeywordStrings.LinearToSRGBConversion);
}
context.ExecuteCommandBuffer(cmd);
CommandBufferPool.Release(cmd);

3D Buffer和UI Buffer的格式:
值得注意的是,Unity默认渲染3D图形的Buffer格式是RGBA111110Float:

如果在这个格式的Buffer里直接利用shader转换色彩空间(LinearToSRGB)会造成3D图像严重的色深精度丢失。所以需要一个和sRGB色彩信息契合的格式来储存Shader转换后色彩空间后的图像信息,所以我选择了RGBA32UNorm作为后续UI Buffer的格式,使用不同格式转换色彩后的色深对比如下:

可以看出来,使用RGBA32UNorm能更好地储存转换后的色深精度。

转换Buffer的方法,我这里未在ForwardRenderer里为UI重新声明创建RT,以及根据是否是UI相机,让Final Blit Pass选择接受不同的Render Target:

RenderTargetHandle m_UguiTaget;
......
m_UguiTaget.Init("_UIColorTexture");
......
void CreateCameraRenderTarget(ScriptableRenderContext context, ref RenderTextureDescriptor descriptor, bool createColor, bool createDepth)
{
    ......
    {
        var uiDescriptor = descriptor;
        uiDescriptor.useMipMap = false;
        uiDescriptor.autoGenerateMips = false;
        uiDescriptor.depthBufferBits = 24;
        uiDescriptor.graphicsFormat = GraphicsFormat.R8G8B8A8_UNorm;
        cmd.GetTemporaryRT(m_UguiTaget.id, uiDescriptor, FilterMode.Bilinear);
    }
    ......
}
......
public override void Setup(ScriptableRenderContext context, ref RenderingData renderingData)
{
    ......
    if (!cameraTargetResolved)
    { 
        RenderTargetHandle finalTarget = isUICamera ? m_UguiTaget : m_ActiveCameraColorAttachment;
        m_FinalBlitPass.Setup(cameraTargetDescriptor, finalTarget);
        EnqueuePass(m_FinalBlitPass);
    }
    ......
}

并在UGUI Pass(DrawObjectsPass)里,依据是否画UI来重新设置Render Target:

/* Add by: Takeshi, Set UI Render target */
if (m_IsGameViewUI && m_UguiTarget != default)
{
    cmd.SetRenderTarget(m_UguiTarget.Identifier());
    context.ExecuteCommandBuffer(cmd);
    cmd.Clear();
}
/* End Add */

context.DrawRenderers(renderingData.cullResults, ref drawSettings, ref filterSettings, ref m_RenderStateBlock);

注:UGUI Pass后面会讲到。

UI的分辨率
因为在3D物体渲染完毕时切换了Buffer,在这里有一次全面重置Buffer尺寸的机会,我们可以修改接下来UI Buffer的分辨率,以达到即使降低3D的渲染质量,也依然能保证UI以满屏幕分辨率渲染。

在前面ForwardRenderer的CreateCameraRenderTarget()方法里创建UI RT时指定新的宽高尺寸:

var uiDescriptor = descriptor;
uiDescriptor.useMipMap = false;
uiDescriptor.autoGenerateMips = false;
uiDescriptor.depthBufferBits = 24;

uiDescriptor.height = Screen.height; /* 设置 UI Render Target 的高度 */
uiDescriptor.width = Screen.width;   /* 设置 UI Render Target 的宽度 */

uiDescriptor.graphicsFormat = GraphicsFormat.R8G8B8A8_UNorm;
cmd.GetTemporaryRT(m_UguiTaget.id, uiDescriptor, FilterMode.Bilinear);

UGUI Pass
Unity默认情况是UI在DrawTransparentObjects Pass里绘制,Game视图因为有独立的UI相机,所以问题不大,但是Scene视图里只有一个相机,借助Render Doc可以看出UI和一般的半透明物体是混在一起的,想在UI和半透明物体之间插入自定义Pass是不可能的。理想状态是:让UI拥有属于自己的Pass,方便后期维护管理。

在Forward Renderer中单独声明了一个DrawOjectsPass类型的UGUI Pass,构造如下:

m_UguiPass = new DrawObjectsPass("UGUI", false, 
             RenderPassEvent.BeforeRenderingTransparents +1, 
             RenderQueueRange.transparent, 
             LayerMask.GetMask("UI"), m_DefaultStencilState,
             stencilData.stencilReference);

用指定的Layer Mask ("UI")来作为这个Pass的渲染条件。使用"UI" layer的Transparent序列物体都会进入这个Pass。

注:不能忘了重新配置Forward Renderer Data, 要把"UI" Layer Mask在Transparent Layer Mask中去掉,否则UI会被DrawTransparentObjects和UGUI这两个Pass重复绘制。

UI和半透明物体分离后,Scene视图也可以方便的校色了。

UI图片的色彩空间
在管线修复后,理论上UI图片就不用勾选sRGB了,但是,我在这里做法是:UI图片维持勾选sRGB,并在UI的Shader里反向矫正回打勾前的状态。

UI Shader的Fragment:

float4 pixel(v2f IN) : SV_Target
{
    ......

    half4 color;
    ......

    color.rgb = lerp(color.rgb,LinearToSRGB(color.rgb),_IsInUICamera);

    // "One OneMinusSrcAlpha".
    color.rgb *= color.a;

    return color;
}

将Linear的Color和sRGB的Color用一个全局变量"_IsInUICamera"为mask进行Lerp插值,全局变量"_IsInUICamera"在DrawObjectPass中实时全局赋值:

public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData)
{
    // NOTE: Do NOT mix ProfilingScope with named CommandBuffers i.e. CommandBufferPool.Get("name").
    // Currently there's an issue which results in mismatched markers.
    CommandBuffer cmd = CommandBufferPool.Get();
    using (new ProfilingScope(cmd, m_ProfilingSampler))
    {
        Camera camera = renderingData.cameraData.camera;
        if (camera.CompareTag("UICamera"))
            cmd.SetGlobalFloat(ShaderPropertyId.isInUICamera,1);

#if UNITY_EDITOR
        else if(m_FilteringSettings.layerMask == LayerMask.GetMask("UI") && renderingData.cameraData.isSceneViewCamera)
            cmd.SetGlobalFloat(ShaderPropertyId.isInUICamera,1);
#endif

        else cmd.SetGlobalFloat(ShaderPropertyId.isInUICamera,0);
        ......
    }
    ......
 }

这样确保只在UI相机渲染阶段走sRGB的渲染流程,非UI相机一切照旧,规避掉因为UI校色而导致非UI相机渲染的UI图片(*例如:世界空间下的UI)颜色不正确。

注:即UI相机中UI的颜色和不透明度都是正确的,非UI相机中的UI颜色正确但不透明度未被矫正。所以暂时不支持非UI相机的不透明度矫正,但也不影响正常使用。

重置UI组件默认Shader
UI组件,比如Image,在不使用自定义材质时,会默认使用Shader "UI/Defaut",而这个Shader是内置不可编辑的,对于我们使用了自定的UI Shader和后期对Shader框架进行扩展就很不方便,我们需要UI组件默认使用我们自己写的Shader。


UI组件默认着色器

好在UI组件的源码是可以编辑的,顺着Image组件的源码,可以看到Image类继承了MaskableGraphic类,MaskableGraphic类又继承了Graphic类,这个就是UI组件的根源了。可以看到Graphic类里有一段设置默认UI Shader的代码。

修改一下:

static public Material defaultGraphicMaterial
{
    get
    {
        // Find Custom UI Shader
        Shader uiShader = Shader.Find("UI/URP_Linear_Space_Default");
        Material uiMaterial = new Material(uiShader);
        if (s_DefaultUI == null)
            //s_DefaultUI = Canvas.GetDefaultCanvasMaterial();
            s_DefaultUI = uiMaterial;
        return s_DefaultUI;
    }
}

这样默认UI Shader就替换成我们自己的了,后面就可以愉快地搭建UI Shader框架了。


修改后的默认UI Shader变成了我们的自定义着色器

五、尾声

最后的最后,是我项目的GitHub地址:
https://github.com/TakeshiCho/UI_RenderPipelineInLinearSpace


这是侑虎科技第1779篇文章,感谢作者七块君供稿。欢迎转发分享,未经作者授权请勿转载。如果您有任何独到的见解或者发现也欢迎联系我们,一起探讨。(QQ群:793972859)

作者主页:https://www.zhihu.com/people/Na-Ka-4921

再次感谢七块君的分享,如果您有任何独到的见解或者发现也欢迎联系我们,一起探讨。(QQ群:793972859)