— Toshio Iwai, diseñador de Electroplankton (2005, Nintendo SPD), en una entrevista de 2006
¿Sabías que *Crypt of the NecroDancer* (2015, Brace Yourself Games — Ryan Clark) fue el primer juego mainstream donde **cada latido del corazón del personaje era un beat musical**? La música se acelera con tu ritmo de movimiento. Si paras, la canción entera se ralentiza. 10 años después, sigue siendo el ejemplo canónico de música adaptativa en juegos 2D. Ryan Clark contó en una GDC que tardó 8 meses en cuadrar la sincronización. Spoiler: lo vamos a hacer en 30 líneas.
El audio es el tema que todo el mundo dice "ya lo haré al final" y luego no hace. Error. El audio define el estado emocional del jugador incluso cuando no se da cuenta. Sin él, tu plataformas se siente como una demo de prensa de 1996. Con él, se siente como un juego.
En este capítulo:
AudioPlayer + PlaybackSettings + AudioSink): la API ECS-first moderna.bevy_kira_audio 0.26 para mixer real, fades y tweens.MusicState resource.AudioChannel<T> (NO un AudioSettings global inventado).AudioPlayer + PlaybackSettingsAntes de meter un crate externo, vale la pena saber qué trae Bevy de serie. Desde 0.15, el audio nativo de Bevy es ECS-first: no hay un recurso Audio al que le pases handles. Reproducir un sonido es spawnear una entidad con un componente AudioPlayer y unos PlaybackSettings. Internamente usa rodio (no OpenAL: eso es un mito que se copia entre tutoriales). Es la API que el equipo de Bevy recomienda para casos simples.
[dependencies]
bevy = { version = "0.19", features = ["wayland"] }
# El feature `audio` es default; trae `AudioPlayer`, `PlaybackSettings`, `AudioSink`.
use bevy::prelude::*;
use bevy::audio::{AudioPlayer, PlaybackSettings, AudioSink};
fn main() {
App::new()
.add_plugins(DefaultPlugins)
.add_systems(Startup, play_bgm)
.run();
}
fn play_bgm(asset_server: Res<AssetServer>, mut commands: Commands) {
// Reproducir = spawnear entidad con AudioPlayer + PlaybackSettings.
commands.spawn((
AudioPlayer::new(asset_server.load("music/bgm.ogg")),
PlaybackSettings::LOOP, // loop infinito
));
}
// Control en runtime: si guardas la entidad, puedes pillar su `AudioSink` y mutarla.
#[derive(Resource)]
struct BgmEntity(Entity);
fn spawn_bgm(mut commands: Commands, asset_server: Res<AssetServer>) {
let e = commands.spawn((
AudioPlayer::new(asset_server.load("music/bgm.ogg")),
PlaybackSettings::LOOP,
)).id();
commands.insert_resource(BgmEntity(e));
}
fn pause_bgm(bgm: Res<BgmEntity>, mut sinks: Query<&mut AudioSink>) {
if let Ok(sink) = sinks.get_mut(bgm.0) {
sink.pause(); // AudioSink SÍ es un componente: sí puedes ponerlo en un Query.
}
}
**AudioPlayer** (componente): la API ECS-first moderna de Bevy 0.19. Lo spawneas en una entidad junto con `PlaybackSettings` y Bevy lanza la reproducción automáticamente. No hay que llamar a `audio.play(...)`.
**PlaybackSettings**: configuración del playback (volumen, loop, velocidad, pausa inicial, spatialización 2D/3D). Constantes útiles: `PlaybackSettings::LOOP`, `PlaybackSettings::ONCE`, `PlaybackSettings::DESYNC`.
**AudioSink** (componente): el "control remoto" de una reproducción activa. Lo pilas con un `Query<&mut AudioSink>` y desde él puedes `pause()`, `resume()`, `stop()`, `set_volume()`, `set_speed()`. SÍ es un componente (a diferencia de `AudioInstance` de kira, que es un asset).
**GlobalVolume** (resource): un volumen global para todo el audio nativo. Útil para un settings menu simple.
**Backend**: `bevy_audio` usa **rodio** (que a su vez usa `cpal`), NO OpenAL. OpenAL es un backend distinto que se menciona en tutoriales antiguos por confusión con otros motores.
Cuándo usar el audio nativo: SFX sueltos, BGM simple, prototipos, juegos donde no necesitas mixer ni fades complejos. Cuándo pasarse a bevy_kira_audio: cuando necesites cross-fades con tweens, múltiples buses independientes, scheduling sample-accurate o control muy fino del mixer. Vamos con eso.
bevy_kira_audio 0.26: el pluginBevy trae un módulo de audio ECS-first (acabamos de verlo), pero para música adaptativa, mixer real, efectos espaciales, fades y tweens sample-accurate, la comunidad usa bevy_kira_audio. Es un wrapper sobre la librería kira, que es la mejor librería de audio de Rust para juegos. La versión compatible con Bevy 0.19 es la 0.26.0.
[dependencies]
bevy = { version = "0.19", features = ["wayland"] }
bevy_kira_audio = { version = "0.26", features = ["ogg"] }
use bevy::prelude::*;
use bevy_kira_audio::prelude::*;
fn main() {
App::new()
.add_plugins(DefaultPlugins)
.add_plugins(AudioPlugin)
.add_systems(Startup, setup)
.run();
}
fn setup(asset_server: Res<AssetServer>, audio: Res<Audio>) {
// El `Audio` resource es el punto de entrada para reproducir sonidos sueltos.
audio.play(asset_server.load("sounds/shoot.ogg")).with_volume(0.5);
}
**kira**: librería de audio Rust de alta calidad. Maneja mixer, sample-accurate scheduling, busses, fades y envelopes. Lo que `bevy_kira_audio` envuelve.
**AudioChannel<T>** (donde T es un marker tuyo): la forma idiomática de tener "buses" en kira. Defines un struct marker `Music`, otro `Sfx`, otro `Ui`, y cada uno es su propio canal tipado con su volumen y su estado independientes. **NO** existe un `AudioSettings { music_volume, sfx_volume, ui_volume }` global: eso es inventado. El control de buses se hace con `AudioChannel<Music>`, `AudioChannel<Sfx>`, etc.
**Handle<AudioSource>**: un identificador a un sonido cargado. Se crea con `asset_server.load("...")`.
**AudioInstance** es un **asset**, NO un componente. Por tanto NO puedes poner `Query<&AudioInstance>`: tienes que acceder vía `Assets` (un `Resource`).
fn play_shoot_sound(
audio: Res<Audio>,
asset_server: Res<AssetServer>,
) {
let handle: Handle<AudioSource> = asset_server.load("sounds/shoot.ogg");
// Reproducir una vez, sin posición espacial.
audio.play(handle);
}
Para algo controlado, guarda el handle de la instancia (un asset, no un componente) en un resource y acude a Assets<AudioInstance> para mutarlo:
// `AudioInstance` es un ASSET. Su handle vive en un resource, no en un Query.
#[derive(Resource)]
struct ShootInstance(Handle<AudioInstance>);
fn spawn_shoot(
mut commands: Commands,
asset_server: Res<AssetServer>,
audio: Res<Audio>,
) {
let handle = asset_server.load("sounds/shoot.ogg");
let instance = audio.play(handle).with_volume(0.6).handle();
commands.insert_resource(ShootInstance(instance));
}
// Para pararlo: accedes vía Assets<AudioInstance> (resource), NO vía Query.
fn stop_shoot(
sound: Res<ShootInstance>,
mut instances: ResMut<Assets<AudioInstance>>,
) {
if let Some(instance) = instances.get_mut(&sound.0) {
instance.stop(AudioTween::linear(Duration::from_millis(200)));
}
}
**AudioTween**: cómo cambia un parámetro de audio en el tiempo. `linear` interpola en línea recta; `ease_in_out` es curva sigmoide (más natural). Úsalo siempre que hagas fades.
**AudioInstance**: el "playback handle" de un sonido. OJO: es un **asset** (vive en `Assets`), no un componente. Intentar hacer `Query<&AudioInstance, With>` NO compila. Para controlarlo en runtime, guarda su `Handle` en un resource y acude a `Assets`.
Si tu juego es 2D y la cámara sigue al jugador, quieres que los enemigos hagan ruido desde donde están. Esto se hace con pan y atenuación por distancia.
fn play_enemy_sound_at(
audio: Res<Audio>,
asset_server: Res<AssetServer>,
enemy_pos: Vec2,
player_pos: Vec2,
) {
let handle = asset_server.load("sounds/enemy_growl.ogg");
// Pan: -1.0 (todo a la izquierda) → 0.0 (centro) → 1.0 (todo a la derecha).
let offset = (enemy_pos - player_pos).x;
let pan = (offset / 400.0).clamp(-1.0, 1.0);
// Volumen: cuanto más lejos, más bajo.
let dist = (enemy_pos - player_pos).length();
let volume = (1.0 - (dist / 800.0)).clamp(0.0, 1.0) * 0.7;
audio
.play(handle)
.with_volume(volume)
.with_panning(pan);
}
¿Sabías que el **panning estéreo** se inventó en 1931 por Alan Blumlein (Inglaterra) para el cine? Patentó el sistema "binaural" mientras trabajaba para EMI. La industria del juego simplemente recicló la idea 60 años después. Alan murió a los 38 en un accidente aéreo probando radares en 1942. Sin él, los enemigos a tu izquierda no sonarían a tu izquierda.
Truco: el oído humano no distingue bien el origen del sonido solo por el pan. También importa la frecuencia (los graves son más difíciles de localizar). Para una mejor espacialidad:
MusicState resourceAquí es donde bevy_kira_audio brilla. La idea: tu juego tiene estados (exploración, combate, victoria, derrota) y cada estado tiene su propia música que entra y sale con fades cruzados.
use bevy_kira_audio::prelude::*;
#[derive(Resource, Default, Debug, Clone, Copy, PartialEq, Eq)]
enum MusicState {
#[default]
Exploration,
Combat,
Victory,
Defeat,
}
#[derive(Resource)]
struct MusicTracks {
exploration: Handle<AudioInstance>,
combat: Handle<AudioInstance>,
victory: Handle<AudioInstance>,
defeat: Handle<AudioInstance>,
}
fn setup_music(
mut commands: Commands,
asset_server: Res<AssetServer>,
audio: Res<Audio>,
) {
let exploration = audio
.play(asset_server.load("music/exploration.ogg"))
.looped()
.with_volume(0.5)
.fade_in(AudioTween::linear(Duration::from_secs(2)))
.handle();
let combat = audio
.play(asset_server.load("music/combat.ogg"))
.looped()
.with_volume(0.0) // empieza en silencio
.handle();
let victory = audio
.play(asset_server.load("music/victory.ogg"))
.looped()
.with_volume(0.0)
.handle();
let defeat = audio
.play(asset_server.load("music/defeat.ogg"))
.looped()
.with_volume(0.0)
.handle();
commands.insert_resource(MusicTracks { exploration, combat, victory, defeat });
commands.insert_resource(MusicState::Exploration);
}
// Recuerda: AudioInstance es un ASSET. Se accede vía `Assets<AudioInstance>`,
// no vía Query. Aquí usamos ResMut<Assets<AudioInstance>>.
fn update_music(
music_state: Res<MusicState>,
tracks: Res<MusicTracks>,
mut instances: ResMut<Assets<AudioInstance>>,
) {
if !music_state.is_changed() {
return;
}
let fade = AudioTween::linear(Duration::from_millis(800));
// Cada pista recibe el volumen correspondiente al estado actual.
let volumes = match *music_state {
MusicState::Exploration => (1.0, 0.0, 0.0, 0.0),
MusicState::Combat => (0.3, 1.0, 0.0, 0.0), // cross-fade
MusicState::Victory => (0.0, 0.0, 1.0, 0.0),
MusicState::Defeat => (0.0, 0.0, 0.0, 1.0),
};
for (handle, target) in [
(&tracks.exploration, volumes.0),
(&tracks.combat, volumes.1),
(&tracks.victory, volumes.2),
(&tracks.defeat, volumes.3),
] {
if let Some(instance) = instances.get_mut(handle) {
instance.set_volume(target, fade);
}
}
}
**Cross-fade**: transición suave entre dos músicas. La anterior baja de volumen mientras la nueva sube. Tarda 800ms en este ejemplo. Más corto = brusco (combate intenso). Más largo = cinematográfico (momento dramático).
**fade_in vs set_volume(0.0) inicial**: el `fade_in` se aplica UNA vez al iniciar la instancia. `set_volume(0.0)` es el estado de reposo. Diferenciar te permite reiniciar el fade cuando cambias de estado por primera vez.
Para cambiar el estado desde tu lógica de juego:
fn enemy_entered_detection_range(
mut music_state: ResMut<MusicState>,
enemies: Query<&Transform, With<Enemy>>,
player: Query<&Transform, With<Player>>,
) {
let p_t = player.single().translation.truncate();
for e in &enemies {
let dist = (e.translation.truncate() - p_t).length();
if dist < 300.0 {
*music_state = MusicState::Combat;
return;
}
}
*music_state = MusicState::Exploration;
}
¡Eso es todo! Cuando un enemigo se acerca, la música pasa a combate en 800ms. Cuando se aleja, vuelve a exploración. Sin cortar nada. Eso es el patrón.
AudioChannel<T> en vez de un AudioSettings globalHasta ahora hemos reproducido sonidos "sueltos". Pero en producción quieres controlar grupos enteros de sonidos:
En bevy_kira_audio esto se hace con canales tipados por marker: defines un struct vacío por bus y obtienes un AudioChannel<Music>, un AudioChannel<Sfx>, etc. No existe un AudioSettings global con campos music_volume / sfx_volume / ui_volume: eso es un mito que circula por tutoriales desactualizados.
use bevy_kira_audio::prelude::*;
// Tres markers de canal: uno por bus. Cada uno es su propio recurso tipado.
#[derive(Resource)]
struct MusicChannel;
#[derive(Resource)]
struct SfxChannel;
#[derive(Resource)]
struct UiChannel;
fn setup_channels(app: &mut App) {
// Registra cada canal en el app. Cada uno tiene su volumen y estado.
app.add_audio_channel::<MusicChannel>()
.add_audio_channel::<SfxChannel>()
.add_audio_channel::<UiChannel>();
}
// Para reproducir algo por un canal concreto:
fn play_on_channels(
music: Res<AudioChannel<MusicChannel>>,
sfx: Res<AudioChannel<SfxChannel>>,
ui: Res<AudioChannel<UiChannel>>,
asset_server: Res<AssetServer>,
) {
music.play(asset_server.load("music/exploration.ogg")).looped();
sfx.play(asset_server.load("sounds/footstep.ogg"));
ui.play(asset_server.load("sounds/menu_click.ogg"));
}
// Para cambiar el volumen de un canal entero (ej. desde un settings menu):
fn set_music_volume(
music: Res<AudioChannel<MusicChannel>>,
settings: Res<GameSettings>,
) {
music.set_volume(settings.music_volume);
}
**AudioChannel<T>**: un canal de audio tipado por un marker tuyo (`MusicChannel`, `SfxChannel`, `UiChannel`). Cada canal es su propio `Resource` con su volumen y estado independientes. Reproduces en un canal con `channel.play(...)` y cambias su volumen con `channel.set_volume(...)`. **NO** uses un `AudioSettings` global con campos `music_volume/sfx_volume`: no existe.
**Duck**: técnica de mezcla donde bajas el volumen de un canal (típicamente música) cuando otro está activo (típicamente diálogo). Es lo que oyes en las películas: la música se "hunde" cuando alguien habla.
Truco: implementa ducking en tu sistema de cinemáticas bajando el volumen del canal de música cuando hay diálogo activo:
fn duck_music_during_dialogue(
dialog_active: Res<DialogActive>,
music: Res<AudioChannel<MusicChannel>>,
) {
let target = if dialog_active.is_active { 0.2 } else { 0.7 };
music.set_volume(target);
}
Reglas prácticas:
#[derive(Resource)]
struct ShootVariants(Vec<Handle<AudioSource>>);
fn load_shoot_variants(mut commands: Commands, asset_server: Res<AssetServer>) {
let variants = vec![
asset_server.load("sounds/shoot_01.ogg"),
asset_server.load("sounds/shoot_02.ogg"),
asset_server.load("sounds/shoot_03.ogg"),
asset_server.load("sounds/shoot_04.ogg"),
];
commands.insert_resource(ShootVariants(variants));
}
fn on_shoot(
variants: Res<ShootVariants>,
audio: Res<Audio>,
) {
let i = (rand::random::<f32>() * variants.0.len() as f32) as usize;
audio.play(variants.0[i].clone()).with_volume(0.4);
}
audio
.play(handle)
.with_playback_rate(0.95 + rand::random::<f32>() * 0.10);
¿Sabías que la técnica de **pitch aleatorio** se popularizó en los juegos de los 90 para que los mismos samples WAV no sonaran idénticos? *Doom* (1993, id Software — John Romero y John Carmack) la usaba para los disparos de la BFG9000. La misma explosión sonaba ligeramente distinta cada vez. 32 años después, sigues sin notarlo conscientemente, pero **lo notas cuando falta**.
Cuando el juego pausa, todo el audio debe pausar. Pero hay un matiz: la música debería pausar, pero el audio de UI del menú de pausa NO.
Como AudioInstance es un asset (no un componente), no puedes iterar instancias con un Query. Lo correcto es pausar el canal entero, o guardar los handles de las instancias que quieras controlar en un resource y operar sobre Assets<AudioInstance>:
// ✅ BIEN: pausar el canal de música (y de SFX) por separado. El de UI queda libre.
fn pause_game_audio(
state: Res<State<GameState>>,
music: Res<AudioChannel<MusicChannel>>,
sfx: Res<AudioChannel<SfxChannel>>,
) {
let paused = *state.get() == GameState::Paused;
// El canal de UI NO se toca: el menú de pausa seguirá sonando.
if paused {
music.pause(AudioTween::linear(Duration::from_millis(300)));
sfx.pause(AudioTween::linear(Duration::from_millis(300)));
} else {
music.resume(AudioTween::linear(Duration::from_millis(300)));
sfx.resume(AudioTween::linear(Duration::from_millis(300)));
}
}
Error clásico: usar `Audio::pause()` globalmente. Pausa TODO, incluido el SFX del cursor del menú. El jugador mueve el ratón sobre "Continuar" y no oye el click. Resultado: tu menú se siente **muerto**. Pausa por canal, no globalmente.
Otro error clásico: intentar `Query<&AudioInstance, With>`. NO compila: `AudioInstance` es un asset, no un componente. Para controlarlo, guarda su handle en un resource y acude a `Assets` (o, mejor, pausa el canal entero como arriba).
bevy_kira_audio no tiene soporte "nativo" para audio 3D posicional completo (no hay HRTF), pero simula la distancia y posición combinando:
AudioInstance::set_playback_rate o bajando el pitch (más bajo el pitch, más grave).fn play_distant_gunshot(
audio: Res<Audio>,
asset_server: Res<AssetServer>,
sound_pos: Vec2,
player_pos: Vec2,
) {
let diff = sound_pos - player_pos;
let dist = diff.length();
let max_dist = 1500.0;
if dist > max_dist {
return; // demasiado lejos, ni lo intentes
}
let t = (dist / max_dist).clamp(0.0, 1.0); // 0 cerca, 1 lejos
let pan = (diff.x / 500.0).clamp(-1.0, 1.0);
let volume = (1.0 - t).powi(2) * 0.8; // caída cuadrática
let pitch = 1.0 - t * 0.3; // más grave cuanto más lejos
audio
.play(asset_server.load("sounds/gunshot.ogg"))
.with_volume(volume)
.with_panning(pan)
.with_playback_rate(pitch);
}
Si necesitas audio posicional real con HRTF, considera bevy_fmod (ver sección 18.11) o usa audio pre-baked (grabas 8 versiones del mismo sonido desde 8 posiciones y los alternas). bevy_audio nativo también expone spatialización básica vía PlaybackSettings::with_spacial() (pan + atenuación simple), pero no es HRTF real.
Si el jugador hace algo y no hay audio, no ha pasado.
Eso es todo el patrón. Pero las consecuencias son muchas:
// ❌ MAL: acción sin sonido
fn player_jumped(mut query: Query<&mut LinearVelocity, With<Player>>) {
let mut v = query.single_mut();
v.y = JUMP_VELOCITY;
}
// ✅ BIEN: acción con sonido reactivo (audio nativo de Bevy 0.19)
fn player_jumped(
mut commands: Commands,
mut query: Query<&mut LinearVelocity, With<Player>>,
asset_server: Res<AssetServer>,
) {
let mut v = query.single_mut();
v.y = JUMP_VELOCITY;
// Spawn de un AudioPlayer efímero: se reproduce una vez y se puede despawnear.
commands.spawn((
AudioPlayer::new(asset_server.load("sounds/jump_01.ogg")),
PlaybackSettings::ONCE.with_volume(bevy::audio::Volume::Linear(0.3)),
));
}
**Reactividad emocional**: el audio no decora; **confirma y amplifica** lo que el jugador acaba de hacer. Es el equivalente auditivo de la animación de impacto visual.
**Dynamic range**: diferencia entre el sonido más bajo y el más alto. Un juego con dynamic range bajo (todo a volumen 0.5) se siente plano. Un juego con range alto (susurros a 0.1 y explosiones a 1.0) se siente vivo.
asset_server.load y deja que Bevy haga streaming. No include_bytes! para música.bevy_fmod: cuando Kira no bastabevy_kira_audio cubre el 90% de los juegos. Pero hay tres casos donde necesitas más: audio posicional 3D real con HRTF (audio binauricular, el que te dice "el enemigo está detrás tuyo a la izquierda"), parámetros en tiempo real (curvas de reverb, pitch, volumen que el juego controla vía eventos), y live updates (el sound designer cambia un parámetro en FMOD Studio y el juego lo refleja sin reiniciar).
Para eso existe bevy_fmod (de Salzian), un wrapper sobre libfmod que te da acceso al motor de audio comercial FMOD Studio desde Bevy.
# Cargo.toml
[dependencies]
bevy = "0.19"
bevy_fmod = "0.6"
//! cap-18 — sección 18.12: setup FMOD.
use bevy::prelude::*;
use bevy_fmod::prelude::*;
fn main() {
App::new()
.add_plugins(DefaultPlugins)
.add_plugins(FmodPlugin::new("assets/audio")) // <-- carpeta de bancos FMOD.
.add_systems(Startup, setup)
.run();
}
fn setup(studio: Res<FmodStudio>) {
studio.load_bank("SFX.bank").unwrap();
studio.load_bank("Music.bank").unwrap();
}
El flujo profesional con FMOD:
PlayExplosion, SetMusicIntensity), parámetros (Reverb: 0.0–1.0, Intensity: 0.0–1.0), buses (Master, Music, SFX).//! cap-18 — sección 18.12: disparar evento FMOD.
fn on_player_shoots(studio: Res<FmodStudio>) {
studio.event("SFX/Explosion").play(); // <-- evento del banco.
}
fn update_music_intensity(studio: Res<FmodStudio>, combat: Res<CombatState>) {
studio.parameter("Music/Intensity").set(combat.intensity); // <-- parámetro en vivo.
}
Cuándo usar bevy_fmod:
Cuándo NO usarlo:
Comparativa rápida:
| Feature | bevy_audio oficial | bevy_kira_audio | bevy_fmod |
|---|---|---|---|
| Coste | Gratis | Gratis | Gratis (indie) / Licencia Pro |
| Backend | rodio / cpal | kira | FMOD (comercial) |
| Audio 3D HRTF | Básico (pan + spatial simple) | Pan + atenuación | Real con HRTF, occlusion, obstruction |
| Música adaptativa | Limitada (AudioSink manual) | Sí (con tweens) | Sí (stems, parámetros, decisiones) |
| Live update | No | No | Sí (FMOD Studio se conecta al juego) |
| Soporte de formatos | OGG, WAV, MP3 | OGG, MP3, FLAC, WAV | Todo lo que FMOD soporta (cientos) |
| Tamaño de la lib | 0 (incluida en Bevy) | Mediano (~5MB) | Grande (~15MB) |
| Curva de aprendizaje | Baja | Media | Alta (FMOD Studio es complejo) |
Regla práctica: empieza con el audio nativo de Bevy (AudioPlayer) para SFX y BGM simple. Si necesitas mixer, fades y cross-fades, salta a bevy_kira_audio. Si en algún momento tu sound designer dice "necesito más control", migra a bevy_fmod. Los conceptos (eventos, buses, parámetros) son similares entre las tres.
★ **Transistor (2014)** — la música de *Transistor* es 8 stems
que se activan según el "modo" de combate. Darren Korb
grabó 60 minutos de música para 6 horas de juego. La
herramienta: FMOD Studio. Es el referente de música
adaptativa en indie.
★ **Hellblade: Senua's Sacrifice (2017)** — el audio 3D
binauricular (HRTF) se grabó con micrófonos en la cabeza
de la actriz. Cada susurro, cada respiración, está
espacializado. El resultado: el jugador siente que está
dentro de la cabeza de Senua. La herramienta: FMOD
Studio + Wwise.
★ **Doom Eternal (2020)** — Mick Gordon mezcló la música en
stems que se activan según la "intensidad" del combate.
0% = exploración tranquila, 100% = boss fight. La
transición es gradual, con cross-fades de 200ms. Es lo
que hace que cada combate se sienta único.
¿Sabías que *Transistor* (2014, Supergiant Games — Amir Rao y Gavin Simon) popularizó la técnica de **"music stems en tiempo real"**? La música no es un único archivo: son 6-8 pistas independientes que se mezclan según el estado del juego. Darren Korb, el compositor, grabó 60 minutos de música para un juego de 6 horas. 11 años después, sigue siendo el referente de música adaptativa en juegos indie. Ah, y la banda sonora la puedes comprar en vinilo.
bevy_seedling / Firewheel: el futuro experimental del audio BevyEl audio en Bevy está en transición. El equipo de Bevy y la comunidad llevan tiempo trabajando en un sucesor del backend actual (bevy_audio con rodio) más flexible y ECS-nativo. Ese esfuerzo se llama Firewheel, y su integración experimental con Bevy se publica como el crate bevy_seedling.
La idea es sustituir el modelo "recurso Audio + sink opaco" por un modelo puramente ECS: cada voz de audio es una entidad con componentes, los nodos del grafo de audio (gain, pan, mixer, reverb) también son entidades, y todo se programa con sistemas normales de Bevy. Es lo que bevy_kira_audio intenta ser pero sin el puente con una librería externa.
Estado (julio 2026): bevy_seedling todavía es experimental. Sirve para prototipar y para seguir la dirección que tomará el audio upstream, pero NO sustituye todavía a bevy_kira_audio para producción. Para juegos serios hoy, quédate con kira o con el audio nativo de Bevy 0.19. Pero merece la pena tenerlo en el radar: cuando se estabilice, la API ECS de Firewheel será probablemente el camino oficial.
**bevy_seedling**: crate experimental que integra **Firewheel** (un motor de audio ECS-nativo) con Bevy. Es la dirección futura planificada para el audio upstream, pero en julio 2026 aún no es estable.
**Firewheel**: el motor de audio grafo-de-nodos que sustenta a `bevy_seedling`. Equivalente conceptual a kira pero diseñado desde cero para ser ECS-first.
AudioPlayer (componente) + PlaybackSettings + AudioSink para control runtime + GlobalVolume. Usa rodio, no OpenAL.bevy_kira_audio 0.26 es la opción cuando necesitas mixer, fades y tweens sample-accurate.AudioInstance es un asset, no un componente: se controla vía Assets<AudioInstance>, no vía Query.AudioChannel<T>, NO un AudioSettings global con campos inventados.audio.play(handle) con with_volume, with_panning, with_playback_rate.set_volume + AudioTween.bevy_fmod. Para el futuro ECS-nativo del audio: bevy_seedling / Firewheel (experimental).En el capítulo 19 (input) vamos a hablar de cómo el jugador dice qué quiere hacer. Keyboard, mouse, gamepad y el patrón de input intents que separa "el jugador apretó A" de "el jugador quiere saltar". Spoiler: el segundo es el que importa.