flutter3d_audio

Positional audio: attenuation, panning, occlusion and voice limiting, with a pluggable backend. It is a sibling of flutter3d_game, not a RenderPlugin: it draws nothing, so the renderer's plugin seam does not apply to it.

It depends on neither flutter3d nor flutter_gpu. It is handed a listener pose and emitter positions, and where those come from is up to the application.

Why the geometry is computed here

SoLoud has a 3D layer, but it cannot be used for anything that moves. Applying a moved source or a turned listener requires update3dAudio(), and flutter_soloud 4.1.7 exposes neither that call nor anything that makes it. Its set3dSourcePosition passes straight through to a SoLoud method that only marks the source dirty. A source set once at play3d never moves again, and in a first-person game the listener turns constantly.

So AudioScene computes gain and pan, and the backend is asked only for a flat voice with a volume and a pan. Every backend can do that, and it takes effect immediately. If a later flutter_soloud exposes the call, the right change is a second backend, not an edit to this one.

Shape

// The game's own sounds.
abstract final class Sounds {
  static const pistol = SoundDef(name: 'pistol', asset: 'audio/pistol.ogg');
  static const door = SoundDef(name: 'door', asset: 'audio/door.ogg');
  static const torch = SoundDef(
    name: 'torch',
    asset: 'audio/torch.ogg',
    loop: true,
  );
}

final scene = AudioScene(
  backend: SoLoudBackend()..open(),
  maxVoices: 24,
  // The walls belong to the physics; this package must not learn about them.
  occlusion: (from, to) => world.raycast(from, to) ? 0.3 : 1.0,
);

await scene.preload(<SoundDef>[Sounds.pistol, Sounds.door]);

scene.play(Sounds.pistol, muzzlePosition);          // one-shot
final hum = scene.play(Sounds.torch, torchPosition); // holds a looping emitter

// Once a frame, after everything has moved.
listener.aimAt(eyePosition, yaw);
scene.update(listener);

play returns an emitter so whatever owns a moving source can move it. The mixer updates that voice instead of restarting it.

Opening the speakers, and where the ears are

openSpeakers opens the device and hands back Speakers: the backend, to pause, resume and dispose, and the scene, to play through. When there is no device it throws AudioDeviceException, and a game catches it and plays silent. What the backend could not play it reports as an Issue, the same type flutter3d_app reports in.

Positions are metres from an origin the game names, as float32 offsets: the scene's origin, which a scene graph keeps near the camera. A place in the world, a WorldPosition in doubles, comes in through placeAt with that origin. Flutter3dView says where the camera is after every frame, so the listener is one line:

Flutter3dView(
  onListenerMoved: (ears) => listener.placeAt(
    ears.position,
    ears.forward,
    origin: ears.origin,
    up: ears.up,
  ),
  onPausedChanged: (paused) =>
      paused ? speakers.pause() : speakers.resume(),
)

What the mixer decides

  • Attenuation is InverseRolloff by default, the only one of the three that sounds right when the player walks past something. LinearRolloff and ExponentialRolloff exist because designers ask for them, and NoAttenuation is for music.
  • Panning uses the left-right component of the direction and nothing else. It is yaw only, so tilting your head back does not swap left and right.
  • Voice limiting ranks by priority first and loudness second. A door closing outranks the ninth footstep even when the footstep is nearer.
  • With instance limiting, ten identical grunts on one frame are a click instead of ten times as loud.

Testing

SilentBackend records every call and makes no sound, so the whole mix is testable without an audio device. A headless build, or a player who has turned sound off, should use it too, instead of a branch at every call site.


Part of flutter3d, an independent implementation of a 3D engine for Flutter. It is not a fork or a binding of another engine, and it is not affiliated with Google or the Flutter project. It has four switchable rendering backends: Impeller via Flutter GPU, WebGL2, WebGPU and a software rasteriser. It loads glTF, OBJ and .f3d, and has six lighting models, shadows, bloom, skinning, animation, BVH culling and picking, plus a deterministic fixed-step game layer with collision, navigation, positional audio, and gamepad and touch input. Four example games (shooter, platformer, racing, strategy) are each built on a genre package: flutter3d_game_shooter, flutter3d_game_platformer, flutter3d_game_racing, flutter3d_game_strategy. A new game starts from the editor's scaffold, which writes one from a template: flutter3d.pleion.dev/first-project/. Documentation: flutter3d.pleion.dev.

Libraries

flutter3d_audio
Positional audio for flutter3d.