3
\$\begingroup\$

Just a heads up: I have very little experience with shader languages so please excuse if this is a trivial question.

I got a SoftLight shader from this blog (Archived here). It blends the given sprite over the background using the SoftLight blending mode without using the GrabPass, because I'm using URP where that is not supported.

The formula on Wikipedia for Photoshop's Soft Light blend mode is:

$$f_\text{photoshop}(a, b) = \begin{cases} 2ab + a^2(1 - 2b), & \text{if}\, b<0.5\\ 2a(1-b) + \sqrt a(2b -1), & \text{otherwise} \end{cases}$$

Though the article I linked uses Pegtop's formula instead:

$$f_\text{pegtop}(a, b) = (1 - 2b)a^2 + 2 ba$$

I would like to add an opacity parameter to it, so I can tune the strength and visibility of the overlaid image, like you would adjust the opacity of the soft light layer in Photoshop. I have an accompanying script that sets the value of _Opacity via MaterialPropertyBlock.

However I cannot get it to work properly, the colors will either become over saturated or too dark.

How can I adjust the opacity properly?

Here is the source, the commented stuff is what I've tried so far.

  1. A new pass to premultiply the alpha of the source image by the opacity.
  2. Multiply the alpha of the texture before blending.
  3. Multiply the alpha of the texture in the last pass (dont know exactly what that does).
Shader "PhotoShop/SoftLight" 
{
    Properties 
    {
        _MainTex ("Base (RGB) Trans (A)", 2D) = "white" {}
        
        [HideInInspector] _Opacity("__opacity", Float) = 1.0
    }
    
    SubShader
    {       
        Tags{ "Queue" = "Transparent" "IgnoreProjector" = "True" "RenderType" = "Transparent" }
        ZWrite Off Lighting Off Cull Off Fog{ Mode Off }
        LOD 110
        
//      Pass
//      {
//          BlendOp Add
//          Blend DstColor DstColor
//
//          CGPROGRAM
//          #pragma vertex vert
//          #pragma fragment frag
//          #pragma fragmentoption ARB_precision_hint_fastest
//          #include "UnityCG.cginc"
//
//          sampler2D _MainTex;
//
//          float _Opacity;
//          struct vin
//          {
//              float4 vertex : POSITION;
//              float2 texcoord : TEXCOORD0;
//              float4 color : COLOR;
//          };
//
//          struct v2f
//          {
//              float4 vertex : POSITION;
//              float2 texcoord : TEXCOORD0;
//              float4 color : COLOR;
//          };
//
//          v2f vert(vin v)
//          {
//              v2f o;
//              o.vertex = UnityObjectToClipPos(v.vertex);
//              o.texcoord = v.texcoord;
//              o.color = v.color;
//              return o;
//          }
//
//          fixed4 frag(v2f i) : COLOR
//          {   
//              float4 color = tex2D(_MainTex, i.texcoord);
//
//              color *= _Opacity;
//              return color;
//          }
//          
//          ENDCG
//      }

        Pass
        {
            BlendOp Add
            Blend DstColor DstColor

            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #pragma fragmentoption ARB_precision_hint_fastest
            #include "UnityCG.cginc"

            sampler2D _MainTex;
            float _Opacity;

            struct vin
            {
                float4 vertex : POSITION;
                float2 texcoord : TEXCOORD0;
                float4 color : COLOR;
            };

            struct v2f
            {
                float4 vertex : POSITION;
                float2 texcoord : TEXCOORD0;
                float4 color : COLOR;
            };

            v2f vert(vin v)
            {
                v2f o;
                o.vertex = UnityObjectToClipPos(v.vertex);
                o.texcoord = v.texcoord;
                o.color = v.color;
                return o;
            }

            fixed4 frag(v2f i) : COLOR
            {   
                float4 color = tex2D(_MainTex, i.texcoord);

                // color.a *= _Opacity;
                
                float3 desiredValue = 2.0 * color.rgb * color.a / (1.0 - 2.0 * color.rgb * color.a);
                float3 backgroundValue = (1.0 - color.a) / ((1.0 - 2.0 * color.rgb * color.a) * max(0.001, color.a));

                color.rgb = desiredValue + backgroundValue;
                return color;
            }
            
            ENDCG
        }

        Pass
        {
            BlendOp Add
            Blend DstColor Zero

            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #pragma fragmentoption ARB_precision_hint_fastest
            #include "UnityCG.cginc"

            sampler2D _MainTex;
            float _Opacity;

            struct vin
            {
                float4 vertex : POSITION;
                float2 texcoord : TEXCOORD0;
                float4 color : COLOR;
            };

            struct v2f
            {
                float4 vertex : POSITION;
                float2 texcoord : TEXCOORD0;
                float4 color : COLOR;
            };

            v2f vert(vin v)
            {
                v2f o;
                o.vertex = UnityObjectToClipPos(v.vertex);
                o.texcoord = v.texcoord;
                o.color = v.color;
                return o;
            }

            fixed4 frag(v2f i) : COLOR
            {   
                float4 color = tex2D(_MainTex, i.texcoord);
                color.rgb *= i.color.rgb;
                color.a *= i.color.a;// * _Opacity;
                color.rgb = (1.0 - 2.0 * color.rgb * color.a) * max(0.001, color.a);
                return color;
            }
            
            ENDCG
        }
    }
}

Image to blend:
enter image description here

Result:
result saturated

UPDATE:

If I enable the commented shader pass and set the alpha source for the overlaid image to "Input Texture Alpha" then it at least produces a better looking result:
enter image description here
But is still doesn't look 100% correct.

\$\endgroup\$
5
  • 1
    \$\begingroup\$ So far as I can tell, the code presented at that site for the Soft Light blend mode is just wrong. Apart from the typos, it leaves a bunch of terms with an extra factor of opacity they shouldn't have. Correcting that, the denominator still drops to/below zero and you get negative values anywhere the top layer has a colour value above 0.5. Correcting this last issue is challenging. By any chance, would your use case permit flipping the order of the layers (so you draw the "top" layer first, then blend the rest over it? If so, there's a very good approximation we can use. \$\endgroup\$
    – DMGregory
    Mar 25, 2023 at 17:28
  • \$\begingroup\$ Oh, I see. When flipping the order, can I still have unaffected layer above that? I wrote the scripts in a way, that the effect object containing the shader can be put in different sorting layers, depending on what should be "covered". \$\endgroup\$
    – Raphael
    Mar 25, 2023 at 19:53
  • 1
    \$\begingroup\$ Baking the opacity doesn't really help, since as you've shown, it can just be multiplied-in within the shader. You could still have standard-blended objects in front of the soft light blending in the reversed case. But what you can't get is a bunch of standard-blended objects "under" the soft light. We'd need the background behind the soft light to be drawn as a single image. Is that feasible? \$\endgroup\$
    – DMGregory
    Mar 25, 2023 at 21:24
  • \$\begingroup\$ Hm, I don't think I can do that easily, since the layer of the effect can change at runtime. \$\endgroup\$
    – Raphael
    Mar 25, 2023 at 22:03
  • \$\begingroup\$ I added an update to the post. It seems to almost work now. \$\endgroup\$
    – Raphael
    Mar 26, 2023 at 19:48

2 Answers 2

1
\$\begingroup\$

The opposite of a right answer 😆

I initially thought I had a very neat solution to this problem. Unfortunately, halfway through typing it up I realized I'd mixed up the roles of \$a\$ (already-in-buffer) and \$b\$ (being-drawn) in the blend formulas, and computed the result of soft-light blending \$a\$ over \$b\$ instead of \$b\$ over \$a\$.

(In my defense, b for "background" or "bottom" seemed very intuitive. To avoid mixing up myself or a reader again, I'll avoid single-letter variable names for the rest of this post and go with the computer graphics convention of "dst" for the accumulated content already in the destination buffer, and "src" for the source image being blended into that buffer)

That said, there may be cases where the designer can re-order the images to be drawn to get the desired result even with this backward blending, and in such a situation this "opposite of right" answer could still be useful, so I figured I'd share it here for future reference.

We'll define our backwards-blending function with opacity \$op\$ as:

$$ f_\text{light-soft}(dst, src, op) = dst + op\left(f_\text{soft light}(src, dst) - dst\right) $$

Using the Pegtop formula, we can rearrange this to factor all the \$dst\$ terms to one multiplication:

$$\begin{align} f_\text{light-soft}(dst, src, op) &= dst + op\left(2src \cdot dst + src^2 - 2 src^2 dst - dst\right)\\ &=dst\left(1 + op (2 src - 2 src^2 - 1) \right) + src^2op \end{align}$$

Now we have something that maps neatly to the GPU blending pipeline:

Pass 1:

Blend DstColor Zero
// ...
color.rgb = 1.0f + color.a * (2.0f * color.rgb * (1.0f - color.rgb) - 1.0f);
return color;

Pass 2:

Blend SrcAlpha One
// ...
color.rgb *= color.rgb;
return color;

The danger with these two-pass approaches of course is that when we have a low dynamic range frame buffer, we have to quantize to 8 bits and clamp the result in the range 0-255 in between the two passes. That's where the shader linked in the question falls over - it relies on negative intermediates and blows up for colours brighter than 50% grey.

So, I wanted to check what kinds of errors this quantization would introduce. I made a spreadsheet testing all 256 x 256 possible combinations of \$src\$ and \$dst\$ values, first computing the correct light-soft blend in one pass, and then computing it with the two passes above with rounding in between. Here's the resulting error map:

Error map

Cells in red are background-foreground pairs that give a value 1/255 lower than the ground truth. Cells in blue give a value 1/255 higher. Everything in white reproduces the correct value exactly. No pair differs from the correctly blended colour by more than one unit in last place, and the intermediate result never underflows or overflows.

So, a very good approximation... to the reverse of the function we wanted. But maybe still useful if you can re-order your layers.

Unfortunately I have not yet found a way to get the rearrangement to work as nicely for the correct original ordering, due to the squares on some of the terms. I'll post a follow-up if I think of a better way.

\$\endgroup\$
7
  • \$\begingroup\$ Thank you very much for this elaborate answer. I will test this approach, and maybe try to get the backwards blending to work. Also thanks to your explanation, I now understand better how these math formulas have to be reordered to work with the shader variables. I have enabled the HDR option in the render pipeline, don't know if that would help prevent the clamping problem? \$\endgroup\$
    – Raphael
    Mar 27, 2023 at 10:38
  • \$\begingroup\$ It could! It should be using a floating point back buffer which would be able to handle negatives and reduce the quantization/range problems — do you see better results with the original shader in HDR mode? \$\endgroup\$
    – DMGregory
    Mar 27, 2023 at 12:49
  • \$\begingroup\$ I cannot really see any difference. I just enabled it because the website said I should. I also switched to Gamma Color Space bc of that. Should I undo that? \$\endgroup\$
    – Raphael
    Mar 27, 2023 at 17:44
  • \$\begingroup\$ Also in your code example, the opacity is always missing. Do I just add * _Opacity before the semicolon on the lines in both passes? \$\endgroup\$
    – Raphael
    Mar 27, 2023 at 18:07
  • \$\begingroup\$ I'm assuming you've multiplied color.a by opacity — either beforehand, or in the shader code just above the snippets I gave — so that all opacity information is in one variable, not two. Outside opacity should not be treated differently than texture opacity. \$\endgroup\$
    – DMGregory
    Mar 27, 2023 at 22:23
1
\$\begingroup\$

So despite my best efforts, I could not get DMGregorys answer to work, which is a shame!

Nonetheless, after several more hours of searching, I finally found a solution:

First I needed to upgrade my Unity to 2021, because the feature used in the following is not available in earlier versions.

Since I am using the URP, I can use its 2D Renderer. That renderer supports the so-called Camera Sorting Layer Texture which works a bit like a GrabPass.

So I set up 2 Sorting layers, one for the stuff below the effect (background), and one for above. In the 2D renderer settings, I then set the "ForemostSortingLayer" to the background layer.

That means the _CameraSortingLayerTexture is taken when all the stuff in the background layer has been rendered.

I then made a quick ShaderGraph, to blend my SoftLight image with the _CameraSortingLayerTexture, to achieve my desired effect.

SoftLight Shader Graph

And it works!

Important to note: The ShaderGraph must be rendered in the Transparent Queue!

\$\endgroup\$

You must log in to answer this question.

Not the answer you're looking for? Browse other questions tagged .