Part 05 — Out of the screen
You will build: the 3D port — AudioStreamPlayer3D on the 3D farm, an ambient bed
anchored to a world position, and the unit_size that makes "metres" mean something to the
mixer.
You'll learn: why a 3D sound is a child of the thing · attenuation as a design number,
not an audio one · the information payoff — a sound that reports a location the player never
reads.
Why this exists#
Parts 01–04 are the same in 2D and 3D: the event, the pool, the bus, the clock. What 3D adds is position — and position changes audio from a layer into a sensor. A 2D hit sound tells you a hit happened; a 3D one tells you where, in the channel the player's ears run in parallel with their eyes. The 3D farm is the test case (its closing stage reserved the harvest sound), and it's a small one on purpose: the 3D audio system is one node type and three numbers, and the three numbers are the design.
Build it#
A — The node: a child of the thing#
AudioStreamPlayer3D is the 3D AudioStreamPlayer: same stream, same play(), same bus
— plus a position, an orientation, and a distance model. The placement rule is the camera's
rule (the survivor's stage 01, the 3D farm's stage 01): the sound is a child of the thing
it describes, so the position is inherited and the one line of code is the one that picks
the unit size.
On the farm's crop, the harvest sound is a child of the Crop node:
Crop (Node3D)
├── Mesh (MeshInstance3D)
└── HarvestSound (AudioStreamPlayer3D) ← stream: the harvest click
When the player harvests (stage 07's requirement — Farm.harvest frees the view), the sound
plays from the crop's cell and the crop is freed — the sound's tail outlives its source by
the length of the clip, which is exactly right: the click is the afterimage of the crop
being gone, the audio version of the survivors game's drop minting a coin at the moment of
death (stage 06). (A freed AudioStreamPlayer3D stops mid-tail; if you want the tail to
survive the crop, play it from the farm — a pool voice, part 03 — positioned at the cell.
The crop's own child is the simple version; the farm-level voice is the "the sound outlives
the thing" version, and the choice is the same containment argument the performance
shelf makes for pools.)
B — The three numbers that are the design#
AudioStreamPlayer3D's distance model has three parameters, and all three are game design
numbers wearing audio names:
max_distance— the radius the sound is audible to. Beyond it, silence. This is the design of "how far away can a golem die and you still hear it" — a farm where the harvest click is audible across the whole 15×15 field is a farm where the sound reports nothing (everything is everywhere); one where it falls off after four cells is a farm where the click localizes the harvest. The survivors game's port sets this to the ring's radius: a death is audible from the edge of the threat, and nothing beyond it.attenuation(the curve) — how the loudness falls off insidemax_distance. The default is a linear-in-distance falloff that sounds "flat" — constant in the near field, a cliff at the edge. The inverse distance curve is the natural one (loudness falls as 1/d, the way sound actually behaves) and the custom curve is where a designer puts a knee — full volume for two cells, then falloff — so the near field reads as "right here" and the far field as "out there" with a designed boundary between. The curve is the difference between "the sound is quieter" and "the sound is further."unit_size— how many game units a metre is. This is the number people skip and the one that breaks the others: attenuation andmax_distanceare computed in metres, and Godot's defaultunit_sizeis 1.0 — one unit per metre. The 3D farm's grid isCELL_SIZE = 1.0metres (stage 04's whole arithmetic is in metres), so the default is correct because the farm chose metre-sized cells — and a farm that chose 0.5 m cells without settingunit_size = 0.5would have every sound fall off at twice the intended rate, and the bug would read as "the audio is weird" with no wrong node to point at.unit_sizeis the contract between the world's scale and the mix's physics; break it and every other number in this part is wrong in a way the inspector can't show.
The doppler (doppler_track, default 1.0) is the fourth number, and the farm doesn't
need it: doppler shifts the pitch with relative velocity, which is the effect a moving
source earns (a golem running past you, a cart) and a static one doesn't. A harvest click
with doppler is a click that pitches as you walk — noise, not information. Leave it at the
default until you have something moving fast enough to deserve the shift, and then the
survivors port's running golems are the test.
C — The ambient bed, anchored to the world#
The farm's weather (the overview's "climate") is an ambient bed — insects, wind, the alien
soil's hum. The placement mistake is making it a child of the player (it follows you, and
"a sound that follows you" is not ambient, it's a companion) or of the camera (worse).
The bed is a child of World, at a fixed position, with max_distance large enough to
cover the field:
World (Node3D)
├── Farm
├── Player
├── Ground
├── AmbientBed (AudioStreamPlayer3D) ← the soil's hum, looped OGG (part 02)
└── GameCamera
Now the bed is in the field: walk to the edge and it's slightly further (the attenuation,
part B, doing its job), walk back and it's closer — the field has a centre of sound the
way it has a centre of gravity, and the player feels the field's extent through their ears
before they see the fence. The loop and the bus are parts 01–02 unchanged (a looped OGG on
the Music bus, When Paused so the weather doesn't cut at the menu); only the node type and
the position changed.
The information test: with your eyes closed, walk the field and name where the Ambient Bed is. If you can't,
max_distanceis too large (the bed is everywhere, reporting nothing) or it's parented to the player (it's you, not the field). A sound you can locate with your eyes closed is a sound that's working as a sensor.
D — The 3D port of the pool#
Parts 03's pool carries over with one change: the voices are AudioStreamPlayer3D, and
play() takes a position — the pool dispatches to the next voice at the event's
location, because a hit sound that plays from the pool's origin (0,0,0) is a hit sound
that's everywhere and nowhere. The voice's position is set per-play, the same way the
float text's position is (the survivor's stage 04: the number leaves the stats node's
position, and the sound leaves the hit's position):
func play_at(stream: AudioStream, at: Vector3, volume_db: float = -6.0) -> void:
var p := _players[_next]
_next = (_next + 1) % _players.size()
p.stream = stream
p.volume_db = volume_db
p.global_position = at
p.play()
The per-voice global_position is the reset that the pool's reset lesson names: a voice carries its last position
into its next job, and a hit sound that plays from the previous hit's location is a sound
that's one event behind — the audio version of the float text arriving pre-shrunk. Set the
position in play_at, every time, and the voice has no memory.
E — Commit#
git add . && git commit -m "audio 05: 3D sounds, unit_size, ambient bed, positional pool"
Checkpoint — definition of done#
- The harvest sound plays from the crop's cell — stand three cells away and it's quieter; stand on it and it's at the near-field level. The falloff is the design (part B), not an accident
-
unit_sizematches the world's metre — the farm'sCELL_SIZEis 1.0 m, the default is 1.0, and you can say what would break if the cells were 0.5 m and theunit_sizewasn't (every falloff at twice the rate, no wrong node to point at) - The ambient bed is a child of
World, and the eyes-closed test (part C) locates it — the field has a centre of sound - The positional pool plays a hit at the hit's position, and a voice carries no memory of its last location (the reset, part D)
- Doppler is at its default and you can say why the farm doesn't need it (no fast moving source — the pitch shift would be noise, not information)
- Zero warnings; committed
Stretch (no instructions)#
A spatial cue the player never reads: the 3D farm's planting sound (the UseItemState's
commit, stage 04) plays at the target cell — one cell in front of the player — and is
audibly ahead of them. The player learns, in a session, that "the sound in front of me is
where I'm about to act," and the facing model (stage 03's target_position) gets an audio
confirmation the eyes don't have to work for. The cue is one play_at and a max_distance
small enough that it's a direction, not a volume.
If you get stuck#
- The 3D sound is everywhere the same (no falloff, no direction) →
max_distanceis at its default (1000.0) — the sound is audible to the edge of the map at full volume, so the distance model has no room to work. Set it to the design radius (part B) and the falloff appears. A 3D sound at default range is a 2D sound with extra steps. - The sound is quiet at the source and normal far away →
unit_sizeis inverted from the world's scale (the world is in centimetres — 100 units per metre — andunit_sizeat 1.0 makes every metre 100× further than the mix expects). The near field is "far" to the attenuation; setunit_sizeto the world's units-per-metre and the contract (part B) holds. - The ambient bed moves when the player turns → it's parented to the camera (which
rotates) or to the player's skin (which faces). Part A's rule: the bed is a child of
World, andWorlddoesn't turn. The parent chain is the position; check it the way the survivor's stage 02 checked the ring's. - A pooled hit sound plays from the wrong cell — consistently one event behind → the
voice's position isn't reset per-play (part D). The voice is carrying its last job's
location into this one.
play_atsetsglobal_positionevery call; aplay()that omits it is the bug.