doc 6 of 6

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 inside max_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 and max_distance are computed in metres, and Godot's default unit_size is 1.0 — one unit per metre. The 3D farm's grid is CELL_SIZE = 1.0 metres (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 setting unit_size = 0.5 would 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_size is 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_distance is 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_size matches the world's metre — the farm's CELL_SIZE is 1.0 m, the default is 1.0, and you can say what would break if the cells were 0.5 m and the unit_size wasn'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_distance is 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_size is inverted from the world's scale (the world is in centimetres — 100 units per metre — and unit_size at 1.0 makes every metre 100× further than the mix expects). The near field is "far" to the attenuation; set unit_size to 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, and World doesn'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_at sets global_position every call; a play() that omits it is the bug.