Sprite rotation shader vanishes at specific viewing angles

I’ve written a sprite shader that rotates a sprite to face the camera along the Y-axis. The rotation itself is perfectly functional, but something strange happens when the sprite is viewed from certain specific angles.

At angles where the camera’s Y rotation differs from that of the sprite’s GameObject, nothing strange happens:
5316816--534750--BillboardShader1.png

…But when they have matching Y rotations, (in this case, both zero) the sprite vanishes at certain values of the camera’s X rotation:

Again, the vanishing only seems to happen (from what I can tell) when their Y rotations match, as you can see here:

The scale of the sprite’s Transform also plays a part in what camera angles make it vanish. I’ve gotten the sprite to disappear a few times by just fiddling with its Z scale.

I’m really puzzled as to what’s causing this. I can only assume it has something to do with how my shader does the rotating, but I can’t figure out what.

Shader Code:

Shader "Sprites/Billboard Y Only"
{
    Properties
    {
        [PerRendererData] _MainTex ("Sprite Texture", 2D) = "white" {}
        _Color ("Tint", Color) = (1,1,1,1)
        _ScaleX ("Scale X", Float) = 1.0
        _ScaleY ("Scale Y", Float) = 1.0
        [MaterialToggle] PixelSnap ("Pixel snap", Float) = 0
    }

    SubShader
    {
        Tags
        {
            "Queue"="Transparent"
            "IgnoreProjector"="True"
            "RenderType"="Transparent"
            "PreviewType"="Plane"
            "CanUseSpriteAtlas"="True"
            "DisableBatching"="True"
        }

        Cull Off
        Lighting Off
        ZWrite Off
        Blend One OneMinusSrcAlpha

        Pass
        {
        CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #pragma multi_compile _ PIXELSNAP_ON
            #include "UnityCG.cginc"
           
            struct appdata_t
            {
                float4 vertex   : POSITION;
                float4 color    : COLOR;
                float2 texcoord : TEXCOORD0;
            };

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

             float4 RotateAroundYInDegrees (float4 vertex, float degrees)
                 {
                     float alpha = degrees * UNITY_PI / 180.0;
                     float sina, cosa;
                     sincos(alpha, sina, cosa);
                     float2x2 m = float2x2(cosa, -sina, sina, cosa);
                     return float4(mul(m, vertex.xz), vertex.yw).xzyw;
                 }

             float4 RotateAroundYInRadians (float4 vertex, float radians)
                 {
                     float sina, cosa;
                     sincos(radians, sina, cosa);
                     float2x2 m = float2x2(cosa, -sina, sina, cosa);
                     return float4(mul(m, vertex.xz), vertex.yw).xzyw;
                 }

            fixed4 _Color;
            float _ScaleX;
            float _ScaleY;

            v2f vert(appdata_t IN)
            {
                v2f OUT;

                IN.vertex *= float4(_ScaleX, _ScaleY, 1.0, 1.0); // Scale X and Y

                float3 camDir = mul((float3x3)unity_CameraToWorld, float3(0,0,-1));        // Camera direction
                camDir = mul((float3x3)unity_WorldToObject, camDir);                    // Put it in Object space (So object rotation won't affect it)
                camDir.y = 0.0;                                                            // Discard Y axis to make it horizontal

                float angle = acos(dot(normalize(camDir), normalize(float3(0.0, 0.0, -1.0))));    // Angle between horizontal camera direction and object forward
                if (sign(camDir.x) < 0.0) { angle *= -1; }        // Check if the angle should be negative

                OUT.vertex = UnityObjectToClipPos(RotateAroundYInRadians (IN.vertex, angle));    // Calculate rotated vertex position (And put it in Clip space)

                OUT.texcoord = IN.texcoord;
                OUT.color = IN.color * _Color;
                #ifdef PIXELSNAP_ON
                OUT.vertex = UnityPixelSnap (OUT.vertex);
                #endif

                return OUT;
            }

            sampler2D _MainTex;
            sampler2D _AlphaTex;
            float _AlphaSplitEnabled;

            fixed4 SampleSpriteTexture (float2 uv)
            {
                fixed4 color = tex2D (_MainTex, uv);

                #if UNITY_TEXTURE_ALPHASPLIT_ALLOWED
                if (_AlphaSplitEnabled)
                    color.a = tex2D (_AlphaTex, uv).r;
                #endif //UNITY_TEXTURE_ALPHASPLIT_ALLOWED

                return color;
            }

            sampler2D _CameraDepthNormalsTexture;

            fixed4 frag(v2f IN) : SV_Target
            {
                fixed4 c = SampleSpriteTexture (IN.texcoord) * IN.color;
                c.rgb *= c.a;
                return c;
            }
        ENDCG
        }
    }
}

I’m not entirely sure what’s wrong with your code, but I will say it’s way more complicated than it needs to be. There’s a lot of expensive trig that can be skipped entirely.

IN.vertex *= float4(_ScaleX, _ScaleY, 1.0, 1.0); // Scale X and Y

float3 upDir = float3(0,1,0);
float3 viewDir = UNITY_MATRIX_V._m02_m12_m22;
float3 rightDir = normalize(cross(viewDir, upDir);
float3 objWorldPos = unity_ObjectToWorld._m03_m13_m23;
float3 worldPos = IN.vertex.x * rightDir + IN.vertex.y * upDir + objWorldPos;

OUT.vertex = mul(UNITY_MATRIX_VP, float4(worldPos, 1));

You could also extract the scale from the original transform matrix on the gameobject itself rather than having to set the scale on the material if you wanted.

If the sprite is backwards, add a - on the viewDir.