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)