This tutorial assumes you know the basics of playing sounds and 3D physics, but it's an "advanced" tutorial, I won't explain line by line what each script does.
And of course, this is all based on my experience, opinions and workflows, as a solo dev. This will be useful for small and medium projects, it's pretty solid but no AAA solution (we'll talk about that at the end).
This was done in Godot 4.1.2
Let's start with the most basic step, you need to store and play multiple sounds, either in a sequence or randomly. Thankfully Godot has a very useful Resource called AudioStreamRandomizer
This neat Resource can be placed in any AudioStreamPlayer, and it will automatically play a a random sound from the pool when you call play(). Remember you can change the max_polyphony from the player to play multiple sounds at the same time, you'll probably want at least two. I also recommend using the default random avoid repeats mode and setting the random volume offset to 0 or a small value.
The only downside to this Resource is that since it uses a custom array export (for the weight sliders), it doesn't support drag and dropping multiple files at the same time like your usual array, you'll have to "add element" and drop the file to the new slot, one by one. (Hopefully this gets fixed in the near future)
How to play this sounds depends on your game, you'll probably want to use the animator method track in a 3rd person one, and a timer or some kind of sine headbob condition in your script for a 1st person one. If you want more realism you also might want to use two players, one for each foot (one in the center is enough for me)
If you only want one type of footstep sounds for your game, this probably is enough.
If you want to have different footstep sounds react to what you're stepping on, here is what we'll do:
- Detect the floor and get it's StaticBody node
- From this node, obtain an identifier
- Using a custom resource, get the corresponding AudioStreamRandomizer from the identifier and assign it to the player
This part is pretty easy, you probably already know, a simple RayCast3D pointing down will suffice.
Remember to keep exclude_parent true! And now let's start with the script, we need to get the floor StaticBody:
extends RayCast3D
@export var steps_player: AudioStreamPlayer3D
func _physics_process(_delta: float) -> void:
if !is_colliding(): return
var col := get_collider() #this is the static body!
We'll expand on this script later
How do we classify them? What can we use to identify what sound to play? This are the options I researched and experimented with:
- Physics Layers
- Custom Metadata Tags
- Node Name
- By Material (from the visual mesh)
But before that, we need a way to store and map our sounds. Instead of doing a giant match/switch statement or a hardcoded dictionary, it's better to take advantage of custom resources. We need two, one that contains the sounds and the identifier, and one that stores an array of those. I will be calling them "Physics Sounds" as a class but you can call them however you like
We'll start with the Custom Tags as an example
This is my personal favorite, as it's a self contained and independent system that can be added, changed or removed without much hassle. The downside is that the whole static body will have the same sounds! (We'll see how to handle this later)
But first let's see how the custom resources look like:
extends Resource
class_name PhysicsSounds
@export var tag_name: StringName
@export var steps: AudioStream
@tool
extends Resource
class_name PhysicsSoundsCollection
@export var default: PhysicsSounds
@export var collection: Array[PhysicsSounds] = []:
set(v):
collection = v
set_map_values()
notify_property_list_changed()
@export var map: Dictionary = {}
func set_map_values() -> void:
map.clear()
for ps in collection: if ps: map[ps.tag_name] = ps
if default: map[default.tag_name] = default
func get_sounds(tag_name: StringName) -> PhysicsSounds:
return map.get(tag_name, default)
func get_steps(tag_name: StringName) -> AudioStream:
return get_sounds(tag_name).steps
The default variable is in case we can't find the tag or if it doesn't have one (Like the purple texture from Source)
This collection resource automatically converts the array into a dictionary, since that's a way easier method to find stuff in runtime. Do not mess with the map property in the editor inspector! There are ways to store it in the resource without exposing it to the inspector, but a bit overkill for this. Also I highly recommend to save our PhysicsSoundsCollection resource somewhere in the project files, and then passing that around for every player character or enemy!
This is how they look like in combination (ignore the impact variable):
Now you might be wandering, how do we add this tag or name to our static bodies? We could do a script to export the property, but in my experience that becomes annoying, and a nightmare if we want to move or delete that script...
Thankfully Godot has a simple but kinda unknown solution to this, metadata!
Metadata can be added to any object, including Nodes! We'll just add an entry called "sounds" with the StringName value of the tag_name we want. Just go to the bottom of the inspector and use the "add metadata" button
Now let's update the Raycast script to search for this metadata:
extends RayCast3D
@export var phys_col: PhysicsSoundsCollection
@export var steps_player: AudioStreamPlayer3D
@export var no_meta_to_default: bool = true
var current_tag: StringName = ""
func _ready() -> void:
steps_player.stream = phys_col.default.steps
func _physics_process(_delta: float) -> void:
if !is_colliding(): return
var col := get_collider()
var col_tag: StringName = col.get_meta("sounds", "")
if !no_meta_to_default && col_tag.is_empty(): return
if current_tag != col_tag:
current_tag = col_tag
print("changed to: [", col_tag, "] thanks to ", col)
change_steps_stream(current_tag)
func change_steps_stream(tag: StringName) -> void:
var new_steps: AudioStream = phys_col.get_steps(tag)
if steps_player.playing: await steps_player.finished
steps_player.stream = new_steps
We only need to change the stream variable of the player when finding a different floor, and also an await safeguard, to avoid interrupting any current sound. The no_meta_to_default is an option on how to handle untagged physics bodies, if we want to change to default or just keep the current one.
If you need to have different sets of sounds in the same StaticBody, you can use Area nodes to override what you need, since Raycast nodes can also detect them (remember to add the metadata also). Just make sure your raycast hits them first, you will need to use their hit_from_inside property, and put the areas a bit higher than the floor (Not really necessary, just in case). If this doesn't work you can script a custom way to do it, but it should be pretty simple.
You could also use node groups for a similar workflow instead of metadata, but I find it to be more bothersome, I prefer using them for other stuff
This is probably the fastest way to work with, simply change the physics layer on the static body and done! But it can be limiting:
- The hard limit of 32 layers
- You can only use "ONE" layer per StaticBody, else we can't compare which one should be active
- You probably want to use layers for other stuff too, like characters or types of areas
- Same as the last method, one set of sounds per StaticBody
Have that in mind before using this method!
Now what do I mean by "one" layer per StaticBody? Let me explain a bit more on how to work with this method:
First, reserve a chunk or two of the Physics flags for this, BUT we won't use them for any actual Physics detection.
Instead, keep using the first flag for "solid" Physics Bodies, and then select ONE of the designated flags for the footstep sounds. This way the player or enemies collision mask only has to have the first default flag to collide with any floor, wall, platform, etc. (like any other project) and not having to constantly remember to add all the possible footstep flags. So yeah, it's only "TWO"
That's how your floor StaticBody layers should look like. No more than TWO layers, always with the first layer active. In this example, the last chunk of flags (25 to 32) are the designated ones for our step sound system. Remember to name them in the project settings!
extends Resource
class_name PhysicsSounds
@export_flags_3d_physics var layer: int
@export var steps: AudioStream
@tool
extends Resource
class_name PhysicsSoundsCollection
@export var default: PhysicsSounds
@export var collection: Array[PhysicsSounds] = []:
set(v):
collection = v
set_map_values()
notify_property_list_changed()
@export var map: Dictionary = {}
func set_map_values() -> void:
map.clear()
for ps in collection: if ps: map[ps.layer] = ps
if default: map[default.layer] = default
func get_sounds(layer: int) -> PhysicsSounds:
layer -= 1 #remove the first flag!
return map.get(layer, default)
func get_steps(layer: int) -> AudioStream:
return get_sounds(layer).steps
extends RayCast3D
@export var phys_col: PhysicsSoundsCollection
@export var steps_player: AudioStreamPlayer3D
@export var no_layer_to_default: bool = true
var current_layer: int = 0
func _ready() -> void:
steps_player.stream = phys_col.default.steps
func _physics_process(_delta: float) -> void:
if !is_colliding(): return
var col := get_collider() as CollisionObject3D
var col_layer: int = col.collision_layer
if !no_layer_to_default && col_tag == 1: return #check if it's only the default layer
if current_layer != col_layer:
current_layer = col_layer
print("changed to: [", col_layer, "] thanks to ", col)
change_steps_stream(current_layer)
func change_steps_stream(layer: StringName) -> void:
var new_steps: AudioStream = phys_col.get_steps(layer)
if steps_player.playing: await steps_player.finished
steps_player.stream = new_steps
This one is self explanatory, instead of using metadata or layers, we search for keywords in the node name. This is dead simple for setting up, but I personally don't like the extra steps to parse it, especially every frame. I recommend to use a special symbol to set the sound type at the end like: "MyStaticBody3D_grass" then split or slice the string with that character.
extends RayCast3D
@export var phys_col: PhysicsSoundsCollection
@export var steps_player: AudioStreamPlayer3D
@export var no_name_to_default: bool = true
var current_name: StringName = ""
func _ready() -> void:
steps_player.stream = phys_col.default.steps
func _physics_process(_delta: float) -> void:
if !is_colliding(): return
var col := get_collider()
var col_name: StringName = col.name.get_slice("_", 1)
if !no_name_to_default && col_name.is_empty(): return
if current_name != col_name:
current_name = col_name
print("changed to: [", col_name, "] thanks to ", col)
change_steps_stream(current_name)
func change_steps_stream(layer: StringName) -> void:
var new_steps: AudioStream = phys_col.get_steps(layer)
if steps_player.playing: await steps_player.finished
steps_player.stream = new_steps
Besides the raycast, the other scripts are the same from the metadata tags examples
Now, I'll be honest, this method is pretty complicated and I have never got to implement it, but I'll sumarize what I researched here. I would try other engines and methods first before doing this from scratch in Godot!
This method will have the most "realistic" result but also a necessity for games that have terrains with multiple materials via vertex painting.
First, how do we exactly use materials as identifiers in our custom resources? Here are some approaches:
-
export a material variable for the PhysicsSounds resource: The most straight forward, but the most annoying to work with. You will have to make many entries for the same sound, as it's unlikely you'll have an unique set of sounds for each material. Also, how many materials will your game have? You will have to remember to add all of them to your collection resource...
-
export an array material variable instead: An improvement, as you would only have one entry per sound. But remember you will have to do an extra check at runtime to see if the material is inside the array!
-
metadata to the rescue again: Just use the custom tag system, but instead add the metadata to your materials. I would personally use this.
-
by file name: Parse the material file name for keywords, pretty easy as in most cases materials have names like metal_shiny, metal_rusted, grass, grass_dead, etc. So if you name them with proper organization you already have them classified.
Now remember the raycast only gets the StaticBody, not the MeshInstance. So we need to get the parent and/or the child and test if it's a MeshInstance... A slight problem: Now you will have to follow absolute rules on how to structure your scenes. Why? because you will get the wrong MeshInstance otherwise.
If you make a level and want to have your meshes unified... do your walls and floor still have the same collider? Do you use a single StaticBody for your whole level? Do you generate your colliders in Godot or in Blender? What if you use bashkits or assets, how are those structured? How do you add props or decorations? If those are childs of the floor, they might get picked up instead if they use a MeshInstance as root.
I can think of some posible solutions for this:
-
follow an strict guideline and rules in your scene tree. It would be something like, all static parts must have the MeshInstance as root, and all the character or rigid bodies the opposite. Or maybe all static parts have a Node3D root and the StaticBody and MeshInstance are siblings (like unity components). Bear in mind not all of Godot follows the same rules, example: MeshInstance can generate colliders as childs or siblings, Characters or RigidBody have to be the root, Gridmaps must always have the MeshInstance as root, etc. so you'll have to constantly check! To me, this is the worst solution
-
@export the MeshInstance in a script that you would add to each of your floors static body. You can also add a NodePath in the metadata, but it's not as convenient in this case, since it doesn't have a class type filter and you still need to use get_node(). This is some extra steps, but it avoids all errors in finding the mesh and it's the most performant.
-
naming convention, make sure your mesh shares a part of the name with it's StaticBody, TileFloor_StaticBody3D and TileFloor_MeshInstance. Then you can relly on this naming convention to scan childs and/or parents nodes and compare the splited names. You could save the pairings at the start or the first time to avoid searching nodes names every time.
Now the other (and hardest) problem: What if you have more than one material in your floor mesh? What if your game uses a terrain system with vertex painted materials?
You can use the Geometry3D helper class to iterate the whole mesh, and identify in what polygon/tri you are on, check with the normal if it's indeed a floor, then which vertex is nearest, and extract the material from that vertex.
Have performance in mind! Your main issue will be the poly count, your subdivisions, you will have to iterate all of them. I would only run it when moving and every few frames, not all the time. And also do a walked distance check, like only when we walked at least 2 or 3 meters from the last time.
An alternative or improvement could be to store the mesh data in parts at the start or in the editor (when importing for example), then only iterate the mesh data the player is on. Like an octree.
I learned most of this from this video: https://www.youtube.com/watch?v=WhMfocT9l-o
It's in Godot 3 and not perfect, but it goes over all we discussed with much more detail (and code). Also read the comments! Some cool approaches there too.
This system can be used for all types of sounds, not only footsteps. Think of when the player hits the wall with a sword, or when shooting a surface, when throwing an object, when landing, etc. We just need to make another StreamAudio variable in our PhysicsSounds. Remember that Areas, CharacterBody and RigidBody also can get the collider!
This is my first time doing a written tutorial, at the beggining I wanted to just get to the point and show how I use this system and my scripts, but then I thought about how each game has it's own necessities and decided to share all that I know about the subject... it's more like an article now lol. Hope you learn something new about Godot with this, it always allows you do things however you like so there's always tons of ways to approach things.
I don't know how I'll do the next tutorials, I want to see what you think of this one first... this took me too much time thou, I need to work on my game too!!
I might also try to make them in video format in the future, but my ingrish pronunciation is quite bad 😅