Search code examples
c#unity-game-engineshaderhlsl

Black hole distortion shader in Unity


I found shader code that has the effect of warping a space around a certain point. It's a cool effect, but it's missing some animation, so I've added something to it:

Shader "Marek/BlackHoleDistortion"
{
    Properties {
        _DistortionStrength ("Distortion Strength", Range(0, 10)) = 0
        _Timer("Timer", Range(0, 10)) = 0
        _HoleSize ("Hole Size", Range(0, 1)) = 0.1736101
        _HoleEdgeSmoothness ("Hole Edge Smoothness", Range(1, 4)) = 4
        _ObjectEdgeArtifactFix ("Object Edge Artifact Fix", Range(1, 10)) = 1
    }
    SubShader {
        Tags {
            "IgnoreProjector"="True"
            "Queue"="Transparent"
            "RenderType"="Transparent"
        }
        GrabPass{ }
        Pass {
            Name "FORWARD"
            Tags {
                "LightMode"="ForwardBase"
            }
            ZWrite Off

            CGPROGRAM
            #include "UnityCG.cginc"
            #pragma vertex vert
            #pragma fragment frag
            #pragma multi_compile_fwdbase
            #pragma only_renderers d3d9 d3d11 glcore gles 
            #pragma target 3.0
            uniform sampler2D _GrabTexture;
            uniform float _DistortionStrength;
            uniform float _HoleSize;
            uniform float _HoleEdgeSmoothness;
            uniform float _ObjectEdgeArtifactFix;
            uniform float _Timer;

            struct VertexInput {
                float4 vertex : POSITION;
                float3 normal : NORMAL;
            };

            struct VertexOutput {
                float4 pos : SV_POSITION;
                float4 posWorld : TEXCOORD0;
                float3 normalDir : TEXCOORD1;
                float4 projPos : TEXCOORD2;
            };

            VertexOutput vert (VertexInput v) {
                VertexOutput o = (VertexOutput)0;
                o.normalDir = UnityObjectToWorldNormal(v.normal);
                o.posWorld = mul(unity_ObjectToWorld, v.vertex);
                o.pos = UnityObjectToClipPos(v.vertex);
                o.projPos = ComputeScreenPos(o.pos);

                COMPUTE_EYEDEPTH(o.projPos.z);

                return o;
            }

            float4 frag(VertexOutput i) : COLOR {
                i.normalDir = normalize(i.normalDir);

                float3 viewDirection = normalize(_WorldSpaceCameraPos.xyz - i.posWorld.xyz);
                float3 normalDirection = i.normalDir;
                float2 sceneUVs = (i.projPos.xy / i.projPos.w);
                float node_9892 = (_HoleSize * -1.0 + 1.0);
                float node_3969 = (1.0 - pow(1.0 - max(0, dot(normalDirection, viewDirection)), clamp(_DistortionStrength - _Timer, 0, _DistortionStrength)));
                float node_9136 = (length(float2(ddx(node_3969), ddy(node_3969))) * _HoleEdgeSmoothness);
                float node_4918 = pow(node_3969, 6.0);
                float node_1920 = (1.0 - smoothstep((node_9892 - node_9136), (node_9892 + node_9136), node_4918));
                float3 finalColor = (
                    lerp(
                        float4(node_1920, node_1920, node_1920, node_1920), 
                        float4(1, 1, 1, 1), 
                        pow(
                            pow(1.0 - max(0, dot(normalDirection, viewDirection)), 1.0), 
                            _ObjectEdgeArtifactFix
                        )
                    ) * tex2D(_GrabTexture, ((node_4918 * (sceneUVs.rg * _Time * -2.0 + 1.0)) + sceneUVs.rg)).rgb).rgb;

                return fixed4(finalColor, 1);
            }
            ENDCG
        }
    }

    FallBack "Diffuse"

}

Now, the problem is that in order to make the distortion disappear after certain time, I need to include some variable into the equation - here I'm calling it _Timer. I'm not using the _Time built in because of obvious reasons - it's an ever growing value and I need something that starts from 0 each time the object using this shader is made active. C# code handling passing that parameter looks as follows:

public void Update() {
    _timeElapsed += Time.deltaTime;

    _renderer.material.SetFloat("_Timer", _timeElapsed);
}

The question is - can I do it better? I would like this shader's code to be more of a self-contained thing - without the need to pass parameters from cs script to it.


Solution

  • Can I do it better?

    In-short, yes and no. If you want the shader to behave differently per material you simply cannot avoid passing a property from C#. You can however avoid doing this in Update by passing a start time and computing the elapse time in the shader.

    C#

    void OnEnable ()
    {
        _renderer.material.SetFloat("_StartTime", Time.timeSinceLevelLoad);
    }
    

    Shader

    uniform float _StartTime;
    
    float4 frag(VertexOutput i) : COLOR
    {
       float elapse = _Time.y - _StartTime;
    }
    

    Now, although this will tie directly into the setup you are currently using, it should be noted that accessing the .material property will clone the material (which can break batching, among other things). This can be avoided with the more recent introduction of MaterialPropertyBlocks.