Confusion while passing arbitrary data to surface shader via vertex UVs

According to docs, UV data is available in surface shaders through the TEXCOORD0…TEXCOORD3 fields:

“TEXCOORD1, TEXCOORD2 and TEXCOORD3 are the 2nd, 3rd and 4th UV coordinates, respectively.”

Questions:

  • When the docs say “2nd, 3rd and 4th UV coordinates”, they are referring to Mesh#uvMesh#uv4, correct?
  • Assuming “yes” to question 1, what about Mesh#uv5Mesh#uv8?
  • The docs say TEXCOORDN can be “float2, float3 or float4”… but the data defined on the Mesh can only be Vector2. Why would TEXCOORDN be anything except float2?
  • I am passing non-UV-related data into UV data fields because there appears to be no other way . Is there really no easier way to pass arbitrary data into a shader?

Thanks and apologies for these questions, which I’m sure are trivial for the experienced folk. I’ve already burned a good amount of time searching online and putzing around in my shader code without success :frowning:

  1. Yes. * (See 3)

  2. Also supported, the Surface Shader documentation just predates support for those additional UV sets and was not updated.

  3. The mesh.uv methods for getting and setting the UV data is indeed limited to Vector2 / float2, but it is really only there for legacy support.

You want to use the mesh.SetUVs() and mesh.GetUVs() functions instead. Those support Vector2/3/4 inputs and outputs.

  1. If you need it to be per vertex, it is the easiest way that works seamlessly with other Unity rendering systems. Alternatively you could use the vertex ID and supply arbitrary arrays, data textures, or structured buffers to hold data. But these don’t play nice with Unity’s batching systems.
2 Likes

@bgolus Thanks very much, this is really helpful!

I’m still confused about how to access all the UV data in the shader, though. Is it always accessible through TEXCOORD0...TEXCOORD3?

I could potentially see that being the case when Mesh#uv...Mesh#uv8 is set to Vector2 data (TEXCOORD0 is populated with Mesh#uv/uv2, TEXCOORD1 with Mesh#uv3/uv4, etc). But I guess the same behavior couldn’t hold for passing Vector3/4 data using Mesh#setUVs because there isn’t enough space in TEXCOORD0...TEXCOORD3.

Edit: Added inline code tags because it seems they actually do work !

Yeah, inline code tags look dreadful in the preview, and quotes. But they do work. For years I used the Courier New font option instead, but stopped because I felt like the inline code tags popped code blocks out a little better.

If we’re talking explicitly about Surface Shaders, then the “trick” is you need to define your own appdata struct, and then access the data in the vertex function.

Shader "Example/Extra Texcoords" {
    Properties {
      _MainTex ("Texture", 2D) = "white" {}
      _Amount ("Extrusion Amount", Range(-1,1)) = 0.5
    }
    SubShader {
      Tags { "RenderType" = "Opaque" }
      CGPROGRAM
      #pragma surface surf Lambert vertex:vert

struct appdata {
    float4 vertex : POSITION;
    float4 tangent : TANGENT;
    float3 normal : NORMAL;
    float4 texcoord : TEXCOORD0;
    float4 texcoord1 : TEXCOORD1;
    float4 texcoord2 : TEXCOORD2;
    float4 texcoord3 : TEXCOORD3;
    float4 texcoord4 : TEXCOORD4;
    float4 texcoord5 : TEXCOORD5;
    float4 texcoord6 : TEXCOORD6;
    float4 texcoord7 : TEXCOORD7;
    fixed4 color : COLOR;
    UNITY_VERTEX_INPUT_INSTANCE_ID
};
     
      struct Input {
          float2 uv_MainTex;
          float4 customData;
      };

      void vert (inout appdata_full v, out Input o) {
          o.customData = v.texcoord7;
      }
      sampler2D _MainTex;
      void surf (Input IN, inout SurfaceOutput o) {
          o.Albedo = tex2D (_MainTex, IN.uv_MainTex).rgb;
          o.Emission = IN.customData; // whatever
      }
      ENDCG
    }
    Fallback "Diffuse"
  }

Though this doesn’t work 100% of the time since some features of Surface Shaders are hard coded to expect the built in appdata_full struct.

1 Like

Ah, awesome!

This helped me smooth out terrain type edges in my prototype square-tile-based world so that it looks a bit less blocky, thanks very much!

Before:

After:
(TODO fix shader logic to prevent dimming)

In case it’s useful for anyone I’ll paste my WIP shader here - the next thing I’m going to do is attempt to replace all the different textures with a texture atlas, but I wanted to get a naiive texture blend working before attempting that.

Shader "Custom/VertexColors" {
    Properties {
        _GrasslandTex ("Grassland Texture", 2D) = "white" {}
        _ForestTex ("Forest Texture", 2D) = "white" {}
        _OceanTex ("Ocean Texture", 2D) = "white" {}
        _LakeTex ("Lake Texture", 2D) = "white" {}
        _RiverTex ("River Texture", 2D) = "white" {}
        _CliffTex ("Cliff Texture", 2D) = "white" {}
        _Glossiness ("Smoothness", Range(0,1)) = 0.5
        _Metallic ("Metallic", Range(0,1)) = 0.0
    }
    SubShader {
        Tags { "RenderType"="Opaque" }
        LOD 200

        CGPROGRAM
        #pragma surface surf Standard fullforwardshadows vertex:vert
        #pragma target 4.0

        struct Input {
            float2 uv_GrasslandTex;
            float2 uv_ForestTex;
            float2 uv_OceanTex;
            float2 uv_LakeTex;
            float2 uv_RiverTex;
            float2 uv_CliffTex;
            float4 color : COLOR;
            // Texture index for the tile to which this vertex belongs
            float self_texture_index;
            // Provided by Unity
            float3 worldPos;
            // Texture indices starting from SquareDirection#North and moving clockwise, ending with SquareDirection#Southeast
            float4 neighbor_texture_indices_from_north;
            // Texture indices starting from SquareDirection#South and moving clockwise, ending with SquareDirection#Northwest
            float4 neighbor_texture_indicies_from_south;
        };

        half _Glossiness;
        half _Metallic;
        fixed4 _Color;
        sampler2D _GrasslandTex;
        sampler2D _ForestTex;
        sampler2D _OceanTex;
        sampler2D _LakeTex;
        sampler2D _RiverTex;
        sampler2D _CliffTex;

        void vert (inout appdata_full v, out Input o) {
            UNITY_INITIALIZE_OUTPUT(Input, o);
            // This is the hack(?) recommended by Unity devs.
            //    The custom data values we want on a per-pixel basis are packed into a number of
            //    otherwise unused fields (tangent, texcoord1, texcoord2).
            //    https://discussions.unity.com/t/383470
            o.self_texture_index = v.tangent[0];
            o.neighbor_texture_indices_from_north = v.texcoord1;
            o.neighbor_texture_indicies_from_south = v.texcoord2;
        }

        float4 eval_texture(float texture_index, Input IN)
        {
            // Comparing with plenty of forgiveness to compensate for the inexact int->float conversion for this data.
            if (texture_index <= 0.5)
            {
                return tex2D (_GrasslandTex, IN.uv_GrasslandTex);
            }
            if (texture_index <= 1.5)
            {
                return tex2D (_ForestTex, IN.uv_GrasslandTex);
            }
            if (texture_index <= 2.5)
            {
                return tex2D (_OceanTex, IN.uv_GrasslandTex);
            }
            if (texture_index <= 3.5)
            {
                return tex2D (_LakeTex, IN.uv_GrasslandTex);
            }
            if (texture_index <= 4.5)
            {
                return tex2D (_RiverTex, IN.uv_GrasslandTex);
            }
            if (texture_index <= 5.5)
            {
                return tex2D (_CliffTex, IN.uv_GrasslandTex);
            }

            return float4(1,1,1,1);
        }

        // Calculations in this method depend on a tile size of 1
        fixed4 calc_blended_texture(Input IN)
        {
            const float tile_origin_offset = 0.5;
            const float x_weight = (IN.worldPos.x + tile_origin_offset) % 1;
            const float z_weight = (IN.worldPos.z + tile_origin_offset) % 1;
            const float4 summed_texture = eval_texture(IN.self_texture_index, IN) +
                eval_texture(IN.neighbor_texture_indices_from_north.x, IN) * z_weight +
                 eval_texture(IN.neighbor_texture_indices_from_north.y, IN) * sqrt(pow(x_weight, 2) + pow(z_weight, 2)) +
                 eval_texture(IN.neighbor_texture_indices_from_north.z, IN) * x_weight +
                 eval_texture(IN.neighbor_texture_indices_from_north.w, IN) * sqrt(pow(x_weight, 2) + pow(1 - z_weight, 2)) +
                 eval_texture(IN.neighbor_texture_indicies_from_south.x, IN) * (1 - z_weight) +
                 eval_texture(IN.neighbor_texture_indicies_from_south.y, IN) * sqrt(pow(1 - x_weight, 2) + pow(1 - z_weight, 2)) +
                 eval_texture(IN.neighbor_texture_indicies_from_south.z, IN) * (1 - x_weight) +
                 eval_texture(IN.neighbor_texture_indicies_from_south.w, IN) * sqrt(pow(1 - x_weight, 2) + pow(z_weight, 2));

            const float diagonal_distance = 1.4142135623730951;
            return fixed4(summed_texture / (5 + 4 * diagonal_distance));
        }

        void surf (Input IN, inout SurfaceOutputStandard o) {
            const fixed4 c = calc_blended_texture(IN);
            o.Albedo = c.rgb * IN.color;
            o.Metallic = _Metallic;
            o.Smoothness = _Glossiness;
            o.Alpha = c.a;
        }
        ENDCG
    }

    FallBack "Diffuse"
}

Apologies to dig this thread up but I have a quick followup for @bgolus or anyone else knowledgeable:

If you were to convert this shader over to URP, what would be your favored approach?

I guess this built-in render pipeline will (eventually) be deprecated in favor of URP where a surface shader equivalent does not (yet) exist. It appears that either shader graph or vert/frag shaders would be the way forward for now, but is one of these two options better than the other in your opinion? Or, does this shader do something which is unachievable by URP right now?

For the URP the best option would be to use Shader Graph. There’s not anything that you’re doing that Shader Graph couldn’t, but there is one gotcha for your specific setup.

I don’t believe there is a way to get the tangent data, not unmodified. Last I looked the value you get from a Shader Graph node always has the xyz normalized. Even if you access the object space tangent in a graph that only gets used to modify the vertex (meaning it runs as part of the vertex shader). So you’ll need to pack that value in something else, likely the z of the main UV.

1 Like

Gotcha, thanks for your time!! <3