Grass Interaction - Part 3

By Myonmyon

In this third part, we will set up the Entity Influence system, calculating non-grass-specific data required by the motion equation.

NOTE: All using declarations are hidden for clarity. You should be able to auto-complete them.

Entities and Entity Influence Registry

We start by defining the interface of our entities:

public interface IEntityInfluenceInfo{
	public Vector3 WorldPos {get;}
	public float Radius {get;}
	public Vector3 WorldVel {get;}
	public float Height {get;}
}

C# doesn’t have multiple inheritance, thus using interface is the most flexible solution. The interface matches closely what we have defined in the compute shader InfluenceInfo. However, since we need to push the data into a buffer, and an interface is not blittable, we need a struct:

public struct EntityInfluenceInfo  
{  
    public Vector3 worldPos;  
    public float radius;  
    public Vector3 worldVel;  
    public float height;  
    public void CopyFrom(IEntityInfluenceInfo info)  
    {  
        worldPos = info.WorldPos;  
        worldVel = info.WorldVel;  
        radius = info.Radius;  
        height = info.Height;  
    }  
}

NOTE: The order of the fields in EntityInfluenceInfo must match InfluenceInfo declared in HLSL. When transmitting data from CPU to GPU, there’s no way to remap fields as the data is essentially raw (void*). Furthermore, the computer does not give you any error message when you get the order wrong except when the ordering changed the size of the struct.

This would seem redundant, having both an interface and a struct, why not just having a struct and store it as a member field? The reason is we want our system to take care of data updating, instead of letting the containing class to call update per-frame (if we only retain EntityInfluenceInfo struct, the containing class will also need to set its values per-frame). This would make the API more friendly.

Next, we will create a class that gathers all the info. For simplicity, we will make it a singleton.

public class EntityInfluenceRegistry  
{  
	private EntityInfluenceRegistry(){}
	private static EntityInfluenceRegistry _instance;  
	public static EntityInfluenceRegistry Instance  
	{  
		get  
		{  
			if (_instance == null)  
		    {  
		        _instance = new EntityInfluenceRegistry();  
			}  
			return _instance;  
		}  
	}
	
}

We need a way to let other components to register entity influence info, as well as removal. Therefore, let’s first add an Add and a Remove method. We are also tracking “large entities” so that we can determine the transcription method.

private List<IEntityInfluenceInfo> _influenceInfos = new();
private const float PerfThreshold = 2;  
private int _largeEntityCount = 0;

public int Size => _influenceInfos.Count;

public void Add(IEntityInfluenceInfo info)  
{  
	_influenceInfos.Add(info);  
	if(info.Radius > PerfThreshold) _largeEntityCount++;  
}  
  
public void Remove(IEntityInfluenceInfo info)  
{  
	_influenceInfos.Remove(info);  
	if(info.Radius > PerfThreshold) _largeEntityCount--;  
}

NOTE: For simplicity, the PerfThreshold is a constant field, but you could definitely expose it as an adjustable field in the inspector.

Since we are here, let’s solve the transcription method first. Create an enum EIRTranscribeMethod

public enum EIRTranscribeMethod  
{  
    /// small entities, large quantity  
    Method1,  
    /// large entities, small quantity  
    Method2  
}

NOTE: “EIR” is short for “Entity Influence Recording”

Then, if we have any entity that is considered “large”, we will use the second method. However, it is totally possible to have one large entity and hundreds of small entities, then both methods would fall short. A possible way to solve it is to split large and small entities to their dedicated buffer, and use the appropriate methods to transcribe them in separated compute passes. The implementation of such approach is left to you. For now, we just naively check if there’s any large entity:

public EIRTranscribeMethod Method => _largeEntityCount > 0 ? EIRTranscribeMethod.Method2 : EIRTranscribeMethod.Method1;

We now need a GraphicsBuffer to transfer the data collected. Unlike a C# List, GraphicsBuffer must have a predefined size. We will use 512 elements for now, but if you do actually have more than 512 entities, you would want to increase that.

private GraphicsBuffer _influenceBuffer;  
private EntityInfluenceInfo[] _stagingBuffer = new EntityInfluenceInfo[512];
private int InfluenceInfoSize => sizeof(float) * 8;

public GraphicsBuffer InfluenceInfo  
{  
	get  
	{  
		if (_influenceBuffer == null || !_influenceBuffer.IsValid())  
		{  
			_influenceBuffer?.Dispose();  
		    _influenceBuffer = new GraphicsBuffer(GraphicsBuffer.Target.Structured, GraphicsBuffer.UsageFlags.None, _infos.Length, InfluenceInfoSize);  
		}  
		return _influenceBuffer;  
	}  
}

NOTE: You may notice there are 3 copies of the same data: one in List, one in EntityInfluenceInfo[], and one in GraphicsBuffer. The GraphicsBuffer resides on the GPU hence is different from the other two that are on the CPU side. The EntityInfluenceInfo[] is like a staging buffer, which contains raw data (values are kept), and the List contains the providers of the raw data (therefore only references are kept).

Finally, we add an Update method to copy data from the list to the staging buffer, and bind it to the graphics buffer:

public void Update()  
{  
	for (int i = 0; i < _influenceInfos.Count; ++i)  
	{  
		_stagingBuffer[i].CopyFrom(_influenceInfos[i]);  
	}  
	InfluenceBuffer.SetData(_stagingBuffer);  
}

We will add a test script called EntityInfluenceProvider. This script has a velocity field that moves the object to the right, and whenever it reaches 4096 it resets its horizontal position to 0.

[ExecuteAlways]  
public class EntityInfluenceProvider : MonoBehaviour, IEntityInfluenceInfo  
{  
    public float radius = 5;  
    public float height = 1;  
    public float vel = 10;  
    private Vector3 _lastPos = Vector3.zero;  
    private Vector3 _velocity = Vector3.zero;  
    private void OnEnable()  
    {  
        EntityInfluenceRegistry.Instance.Add(this);  
    }  
  
    private void OnDisable()  
    {  
        EntityInfluenceRegistry.Instance.Remove(this);  
    }  
  
    private void Update()  
    {  
        _velocity = (transform.position - _lastPos) / Time.deltaTime;  
        _lastPos = transform.position;  
        transform.position += Vector3.right * (Time.deltaTime * vel);  
        if (transform.position.x > 4096)  
        {  
            var vector3 = transform.position;  
            vector3.x = 0;  
            transform.position = vector3;  
        }  
    }  
    public Vector3 WorldPos => transform.position;  
    public Vector3 WorldVel => _velocity;  
    public float Radius => radius;  
    public float Height => height;  
}

Custom Pass

This is the part where HDRP and URP part ways - they offer different API for doing this. Though in a nutshell, what we will be doing is setup some compute dispatch calls, and it should be fairly easy to do in either pipelines.

public class EntityInfluenceRecordingPass : CustomPass  
{  
    // Shader parameters 
    private static readonly int InfluenceTexture = Shader.PropertyToID("_InfluenceTexture");  
    private static readonly int InfluenceTextureParams = Shader.PropertyToID("_InfluenceTextureParams");  
    private static readonly int InfluenceInfo = Shader.PropertyToID("_InfluenceInfo");  
    private static readonly int InfluenceInfoCount = Shader.PropertyToID("_InfluenceInfoCount");  
    private static readonly int Time1 = Shader.PropertyToID("_TimeParams");  
    
    // Serialized properties
    public ComputeShader scriptoriumShader;  
    public float coverageSize;  
    public Vector2 offset;  
    public int textureSize;  
      
    // Under the hood stuff
    private RenderTexture _influenceTexture;  
    private Vector4 _influenceTextureParams;  
    private bool _initSucceeded = false;
    // Kernels  
    private int _updateTimeKernel;  
    private int _transcribeKernel1;  
    private int _transcribeKernel2;
}

Next, we will initialize all the required resources in the Setup method:

        protected override void Setup(ScriptableRenderContext renderContext, CommandBuffer cmd)  
        {  
            // sanity check
            Assert.IsNotNull(scriptoriumShader, "EIR: ScriptoriumShader is null");  
            Assert.IsTrue(coverageSize != 0, "EIR: CoverageSize is 0");  
            Assert.IsTrue(textureSize != 0, "EIR: TextureSize is 0");  
            base.Setup(renderContext, cmd);  
            // texture creation
            var descriptor = new RenderTextureDescriptor(textureSize, textureSize)  
            {  
                graphicsFormat = GraphicsFormat.R16G16B16A16_SFloat,  
                depthBufferBits = 0,  
                depthStencilFormat = GraphicsFormat.None,  
                enableRandomWrite = true,  
            };  
  
            _influenceTexture = new RenderTexture(descriptor);  
            _influenceTexture.Create();
            // finding kernels
            _updateTimeKernel = scriptoriumShader.FindKernel("UpdateTime");  
            _transcribeKernel1 = scriptoriumShader.FindKernel("TranscribeMethod1");  
            _transcribeKernel2 = scriptoriumShader.FindKernel("TranscribeMethod2");  
  
            // set params only once in build to reduce overhead  
#if !UNITY_EDITOR  
            SetParams(cmd);  
#endif  
            _initSucceeded = true;  
        }  
  
        private void SetKernelParams(CommandBuffer cmd, int kernelID)  
        {  
            cmd.SetComputeBufferParam(scriptoriumShader,kernelID, InfluenceInfo, EntityInfluenceRegistry.Instance.InfluenceBuffer);  
            cmd.SetComputeTextureParam(scriptoriumShader, kernelID, InfluenceTexture, _influenceTexture);  
        }  
  
        private void SetParams(CommandBuffer cmd)  
        {  
            _influenceTextureParams = new Vector4(coverageSize / textureSize, textureSize, offset.x, offset.y);  
            cmd.SetGlobalTexture(InfluenceTexture, _influenceTexture);  
            SetKernelParams(cmd, _transcribeKernel1);  
            SetKernelParams(cmd, _transcribeKernel2);  
            SetKernelParams(cmd, _updateTimeKernel);  
            cmd.SetComputeFloatParam(scriptoriumShader, LerpFactor, lerpFactor);  
            cmd.SetGlobalVector(InfluenceTextureParams, _influenceTextureParams);  
        }

NOTE: We use CommandBuffer.Set... methods because they generally have less overhead compared to Shader.Set... methods. The exact implementation is hidden from us, but it is possible that Shader.Set... methods need to create onetime-submit command buffers. Benchmarking showed CommandBuffer.Set... methods are 1.5x-2x faster than equivalent Shader.Set... methods.

NOTE: Resource bindings only need to be set once. This is why I put SetParams call in Setup rather than Execute, since they won’t change in the built game. In Editor it is called per-frame since we might want to adjust constants in real-time.

And now in Execute, we trigger the update on EntityInfluenceRegistry and dispatch the two computes:

        protected override void Execute(CustomPassContext ctx)  
        {  
            if (!_initSucceeded) return;  
            base.Execute(ctx);  

			// trigger registry update
            EntityInfluenceRegistry.Instance.Update();  
            // set influence info count (array bound)
            scriptoriumShader.SetInt(InfluenceInfoCount, EntityInfluenceRegistry.Instance.Size);  
            // update time
            scriptoriumShader.SetVector(Time1, new Vector4(Time.deltaTime, 0, 0, 0));  
            var cmd = ctx.cmd;  
            // In Editor mode, set params every frame to support param adjustments.  
#if UNITY_EDITOR  
            SetParams(cmd);  
#endif  
            cmd.BeginSample("EntityInfluence Time Update");  
            cmd.DispatchCompute(scriptoriumShader, _updateTimeKernel,  
                textureSize / 8, textureSize / 8, 1);  
            cmd.EndSample("EntityInfluence Time Update"); 
             
            cmd.BeginSample("EntityInfluence Transcribe");  
            cmd.DispatchCompute(scriptoriumShader,  
                EntityInfluenceRegistry.Instance.Method == EIRTranscribeMethod.Method1  
                    ? _transcribeKernel1  
                    : _transcribeKernel2,  
                textureSize / 8, textureSize / 8, 1);  
            cmd.EndSample("EntityInfluence Transcribe");  
        }

Finally in Cleanup, release the texture:

protected override void Cleanup()  
{  
    _influenceTexture.Release();  
}

That concludes the custom pass. Create a Custom Pass Volume in the Editor and hook up the EIR pass:

Debugging

Let’s setup a simple shader to visualize our influence texture:

It samples the global texture _InfluenceTexture, and that’s it. Remember to set the scope of the influence texture parameter to Global or else it won’t work.

Create a material from this shader, add an UI Image and assign the image with the material created.

Now create an empty object and add an EntityInfluenceProvider - the test script we created earlier. You should see a circle in the UI image:

Here I set the radius to 50 so that it could be seen clearly. If the velocity of the object is not zero then you should see it leaving a trail behind:

Even if you disable the object, the trail should stay. You could modify the visualizer so that we take time into account:

Play around with the setup and check if everything work as expected so far.

In the next part we will finish the final piece of the puzzle - vertex displacement in shader.

Share: X (Twitter) Facebook LinkedIn