Pass Dynamic List of Data to Shader

I want to pass a list of data to the shader. The lenght is dynamically determined at runtime.

As I understand so far, array shader properties are precompiled and thus cannot have a dynamic size. I thought passing a texture could be a solution. However, all my experiments are not successful for some reason.

When using a texture, I need to pick a value from an absolute position without any sampling mechanism distorting the values by surrounding texels.

At first let’s just draw points from a Vector2/float2 list on a simple plane (UV 0…1).

How can I achieve what I intended? I want to lern both, how to handle a procedural texture in raw mode and whether there is a better approach than using a map.

Undesired behaviour: The dynamically fetched points are not visible at all. (The hard-coded point in the center actually is.)

Observation: The debug output indicatates that the material seem to has stored the shader property successfully. The correct dimensions are reread which suggests the problem should be located in my shader code.

My code so far:

MonoBehaviour

using NaughtyAttributes;
using UnityEngine;
using UnityEngine.Serialization;

namespace Shaders
{
  public class PointsBehaviour : MonoBehaviour
  {
    [SerializeField, OnValueChanged(nameof(PropertyChanged))] // [modified] NaughtyAttributes
    private Vector2[] _points =
    {
      new Vector2(.2f, .2f),
      new Vector2(.2f, .8f),
      new Vector2(.8f, .2f),
      new Vector2(.8f, .8f),
    };
  
    private Renderer _renderer;
    private int      _PointsId;


    private void OnEnable()
    {
      this.PropertyChanged("");
    }

    private void init()
    {
      // this._meshFilter   = GetComponent<MeshFilter>();
      this._renderer = GetComponent<Renderer>();
      this._PointsId     = Shader.PropertyToID("_PathCoords");
      Debug.Log("Initialized");
    }

    
    #if UNITY_EDITOR
    // called by modified NaugtyAttributes, could be done in OnValidate
    public void PropertyChanged(string propertyName)
    {
      //if(!_renderer)
        init();
      
      var size  = _points.Length;
      var tex   = new Texture2D(size, 1, TextureFormat.RGFloat, false);
      
      // to keep it simple, using ordinary array, also tried native array
      tex.SetPixelData(_points, 0, 0);
      this._renderer.sharedMaterial.SetTexture("_Points", tex );
      // this._renderer.sharedMaterial.SetTexture(_PointsId, tex );

      Texture2D t2d = (Texture2D) this._renderer.sharedMaterial.GetTexture("_Points");
      // Texture2D t2d = (Texture2D) this._renderer.sharedMaterial.GetTexture(_PointsId);
      Debug.Log($"sizeof tex: ({t2d.width} x {t2d.height })" );   // => sizeof tex: (4 x 1)
    }
    #endif
  }
}

Shader

Shader "Unlit/PointShader"
{
    Properties
    {
        _MainTex ("Texture", 2D) = "white" {}
        _Points  ("Texture", 2D) = "white" {}
    }
    SubShader
    {
        Tags { "RenderType"="Opaque" }
        LOD 100

        Pass
        {
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            // make fog work
            #pragma multi_compile_fog

            #include "UnityCG.cginc"

            struct appdata
            {
                float4 vertex : POSITION;
                float2 uv     : TEXCOORD0;
            };

            struct v2f
            {
                float2 uv : TEXCOORD0;
                UNITY_FOG_COORDS(1)
                float4 vertex : SV_POSITION;
            };

            sampler2D _MainTex;
            float4 _MainTex_ST;

            // sampler2D _Points;
            Texture2D<float2> _Points;
            float4 _Points_ST;

            sampler2D_float _PointsSampler;

            
            float4 DrawPoint(in float2 p, in float2 uv, in float4 currentColor)
            {
                const float3 dotColor = float3(1.0, 0.0, 0.3);
                const float3 col = currentColor.xyz;
                                
                float len = length(p-uv);
                len = saturate(abs(len));
                
                float stp = step(len, 0.02);
                currentColor = float4(lerp(col, dotColor, stp), currentColor.w);
                return currentColor;
            }
            
            v2f vert (appdata v)
            {
                v2f o;
                o.vertex = UnityObjectToClipPos(v.vertex);
                o.uv = TRANSFORM_TEX(v.uv, _MainTex);
                UNITY_TRANSFER_FOG(o,o.vertex);
                return o;
            }

            fixed4 frag (v2f i) : SV_Target
            {
                // sample the texture
                fixed4 col = tex2D(_MainTex, i.uv);


                
                float2 p;

                // constant point does work
                p = (.5,.5);
                col = DrawPoint(p, i.uv, col);

                // loaded point does not show
                p = _Points.Load(int3(0,0, 0));
                col = DrawPoint(p, i.uv, col);
                              
               
                // apply fog
                UNITY_APPLY_FOG(i.fogCoord, col);
                return col;
            }
            ENDCG
        }
    }
}

First thing to note is that you need to call Texture2D.Apply() when modifying a texture’s contents, otherwise they will not be pushed to the GPU (hence why your points are not visible).

That aside, even though shader arrays do require known sizes at compile time, depending on the number of points you want to draw there’s nothing stopping you from just using a large array and dynamically populating only what you need:

const int MAX_SIZE = 512;

void Start ()
{
    // Bind the full size array first to define the maximum size
    _Material.SetVectorArray ("_Points", new Vector2[MAX_SIZE]);
}

...

List<Vector2> _GridPoints = new List<Vector2> ();
// 37 points used here as a random number
for (int i = 0; i < 37; i++)
{
    // Just fill the points in a grid until we run out of space
    _GridPoints.Add (new Vector2 (i % 10f, i / 10) / 10f);
}

// Now, bind the array to the shader
_Material.SetVectorArray ("_Points", _GridPoints);
// Also bind the actual number of points
_Material.SetInt ("_PointCount", _GridPoints.Count);

Note that you may need to use Vector4’s here in order to call SetVectorArray() (there might be overloads, can’t tell from the docs). As for the initial bind, see the notes at the bottom of the [API Reference.][1]

Then, in the shader you can just declare the full-size constant array and simply loop up to the dynamic count (all data afterwards is garbage):

// Make sure this is the same size as the one in C#
float2 _Points[512];
int _PointCount;

...

// Take the min of our dynamic count and the array size to make sure we don't go over (also hints a max loop count to the shader compiler)
for (int i = 0; i < min (_PointCount, 512); i++)
{
    col = DrawPoint (_Points*, i.uv, col);*

}
You may need to adjust the types used in the binding (I forget if Unity actually binds vector arrays based on their type’s size or just blindly maps data to a contiguous block) but that should work. I would recommend setting the maximum array size as small as possible (512 is probably as high as I would go, any more and there are probably more efficient ways to do what you want).
[1]: Unity - Scripting API: Material.SetVectorArray