Variable Footstep Sounds (That depends on surface)

Does anyone know a good tutorial or script that shows me how I would go about implementing footstep sounds that are played from certain footstep sound groups in example if you walked on metal, you’d hear footstep sounds from the metal sound group??? (I would like to try and play these accordingly by detecting the material that the player is walking on.) Also I need to be able to do this without animation events since I am currently using a FP controller that isn’t animated.

(BTW I am using unity 2020.3 LTS Just for a reference for people in the future who are looking at this forum post trying to figure this out.)

I don’t have a tutorial.
I was hesitant to reply, because this problem encompasses quite a lot of things.

  • Defining surfaces

  • Physics Raycasting

  • Terrain Splat maps

  • Playing Audio

I cannot give a clear and concise answer.
But since on one has replied so far, I’d figure I at least share some insight in how we do it:

This is a simplified version of how we tackle the problem.
We support both terrain collision, and gameobjects with a collider that have an added component ‘SurfaceMaterialID’.

The gist of it is as follows:

SURFACE:

  • We define each surface as a ‘SurfaceMaterial’
  • It contains an enum AND a terrain layer for identification

DATA:

  • We keep knowledge of all these materials in an asset (scriptable object) called ‘SurfaceMaterialData’
  • This asset is referenced by our player object

RESOLVING:

  • Each step we raycast down (Only to collision layer marked as ‘Ground’).
  • If we hit terrain, we match the definitions based on the terrainLayer with the greatest opacity
  • If we hit a collider, we check if it has a ‘SurfaceMaterialID’ component, and match it against that id.
  • Now that we have the SurfaceMaterial, we simply play one of it’s files

Our system has been extended to support multiple terrain layers,
blending of footsteps based on the alpha of overlapping terrain layers,
passing through foliage or water, shifting the panning and volume for each step, and all that jazz.
Once you have the basics down, it’s quite easy to extend.

It’s a bit fiddly getting the terrain splat maps, so I’m including some example code.
I’d share more of the system, but it would take quite a lot of work to extract the complete model and simplifying it down for demonstration purposes.
Please use this code as a guideline, not as an implementation.
It is untested.

SURFACE:

[System.Serializable]
public class SurfaceMaterial // Map a bunch of clips to both an identity (enum value) and a terrain layer
{
    public SurfaceIdentity identity;
    public TerrainLayer terrainLayer;
    public AudioClip[] clips;
}
public enum SurfaceIdentity { Grass, Mud, Sand, Tile, [...] } // Some ID's

DATA:

[CreateAssetMenu(fileName = "Audio/SurfaceMaterialData")]
public class SurfaceMaterialData : ScriptableObject // Now we can reference our materials by 'Id' or terrain layer
{
    [SerializeField] private List<SurfaceMaterial> surfaceMaterials = new List<SurfaceMaterial>();

    public SurfaceMaterial FindSurfaceMaterial(TerrainLayer layer) { [...] } //Iterate the list and compare layers

    public SurfaceMaterial FindSurfaceMaterial(SurfaceMaterialId id) => FindSurfaceMaterial(id.identity);

    public SurfaceMaterial FindSurfaceMaterial(SurfaceIdentity identity) { [...] } //Iterate the list and compare identities
}

RESOLVING:

[RequireComponent(typeof(SurfaceMaterialIdentifier))]
public class FootstepPlayer : MonoBehaviour // I split up the player from the 'surface-identifying-raycast' thing. We have lots of other code here...
{
    protected void OnFootstepTaken(FootstepArgs args)
    {
        Ray r = new Ray(CurrentPosition + Vector3.up, Vector3.down);
        SurfaceMaterial castResult = Identifier.Cast(r);

        // You can do a lot of fun stuff with panning or volume here.

        if (castResult != null)
            Play(castResult.clips); // pick one at random
    }
}

public class SurfaceMaterialIdentifier : MonoBehaviour // The meat and bone of the system
{
    private static readonly string layerToCastTo = "Ground";
    private static readonly float maxCastDistance = 10f;
 
    [SerializeField] private SurfaceMaterialData _surfaceData = null;

    public SurfaceMaterial Cast(Ray r)
    {
        if (_surfaceData != null)
        {
            RaycastHit hitInfo;
            if (Physics.Raycast(r, out hitInfo, maxCastDistance, LayerMask.GetMask(layerToCastTo), QueryTriggerInteraction.Ignore))
            {          
                Terrain terrain = hitInfo.transform.GetComponent<Terrain>();
                if (terrain != null)
                {
                    return CastTerrain(terrain, hitInfo.point);
                }
          
                SurfaceMaterialId id = hitInfo.transform.GetComponent<SurfaceMaterialId>();
                if (id != null)
                {
                    SurfaceMaterial material = _surfaceData.FindSurfaceMaterial(id);
                    if (material != null)
                    {
                        return material;
                    }
                }
            }
        }
        else
        {
            Debug.LogError("SurfaceData not assigned", this);
        }
        return null;
    }

    private SurfaceMaterial CastTerrain(Terrain terrain, Vector3 position)
    {
        TerrainData data = terrain.terrainData;

        int terrainInstanceId = terrain.GetInstanceID();
        float[,,] splatMapData = data.GetAlphamaps(0, 0, data.alphamapWidth, data.alphamapHeight); // In our dev code, I cache this result, not having to fetch it every step.
        int splatTextureCount = splatMapData.Length / (data.alphamapWidth * data.alphamapHeight);
  
        Vector3 localHit = terrain.transform.InverseTransformPoint(position);
        Vector3 splatPosition = new Vector3(
            (localHit.x / data.size.x) * data.alphamapWidth,
            0,
            (localHit.z / data.size.z) * data.alphamapHeight);

        //Get the most opaque splat
        float maxOpaque = 0f;
        int index = -1;
        for (int i = 0; i < splatTextureCount; i++)
        {
            float opacity = splatMapData[(int)splatPosition.z, (int)splatPosition.x, i];
            if (opacity > maxOpaque)
            {
                maxOpaque = opacity;
                index = i;
            }
        }

        //Fetch
        TerrainLayer layer = data.terrainLayers[index];
        SurfaceMaterial surfaceMaterial = _surfaceData.FindSurfaceMaterial(layer);
        return surfaceMaterial;
    }
}

I figured some chopped up code examples are beter than no answer at all.
Hope this helps you!

(Some edits for clarity)

7 Likes

Amazing reply really. This is very useful. Thank you!

Doesn’t it make more sense to just check the collider tag of the collider? That way you avoid the overhead GetComponent<>() call? What if you have 100 enemies all running this code?

The example code looks to be oriented for a player character. So it’s better for it to be flexible, rather than performant. 100+ NPCs is a completely different case to the one the code is trying to solve.

Tags are super limiting design-wise and architecture wise, in any case. Better to avoid them unless absolutely necessary.

You can definitely design games without them these days.

1 Like

Instead of tags, I use the collider’s material (PhysicsMaterial) reference as a key. Oh, you hit a collider with SandMaterial, let’s play the associated sound. You can also get fancy and differentiate sounds with force or incident angle, or choose a sound style based on which of the two materials in a collision is more important, so you can support barefoot/sandal/boot vs metal/water/sand/wood.

2 Likes

Is the physics material cached with the collider? This seems like a good solution, have you used it with many NPCs before?

Every collider has a physics material property: https://docs.unity3d.com/ScriptReference/Collider-material.html

Only issue I can see is potentially having to null-check it.

Yup, null is just another valid key in the “choose a footstep” manager.

As for performance testing, and this goes for everything you set up, put together a test scene that pushes the feature 10x, 100x, 1000x more than you ever expect to use it. Focus on the subsystem in some tests, and the whole integrated game in other tests. I don’t make games with 1000 NPCs, but I can still set up a test that has a bunch of NPCs or a bunch of footstep calls and profile it to look for improvements.

3 Likes