Sincronizar el juego con el audio y la música

Introducción

En cualquier aplicación o juego, la reproducción de sonido y música tendrá un ligero retraso. Para los juegos, este retraso es a menudo tan pequeño que es insignificante. Los efectos de sonido saldrán unos milisegundos después de que se llame a cualquier función play(). En el caso de la música, esto no importa, ya que en la mayoría de los juegos no interactúa con la jugabilidad.

Aún así, para algunos juegos (principalmente, juegos de ritmo), puede ser necesario sincronizar las acciones de los jugadores con algo que sucede en una canción (normalmente en sincronización con el BPM). Para ello, es útil tener una información de tiempo más precisa para una posición de reproducción exacta.

Es difícil lograr una precisión de tiempo de reproducción muy baja. Esto se debe a que hay muchos factores en juego durante la reproducción de audio:

  • El audio se mezcla en trozos (no continuamente), dependiendo del tamaño de los búferes de audio utilizados (compruebe la latencia en la configuración del proyecto).

  • Los trozos mezclados de audio no se reproducen inmediatamente.

  • Las API de los gráficos muestran dos o tres fotogramas de retraso.

  • Cuando se reproduce en los televisores, se puede añadir algún retraso debido al procesamiento de la imagen.

La forma más común de reducir la latencia es encoger los búferes de audio (de nuevo, editando el ajuste de latencia en los ajustes del proyecto). El problema es que cuando la latencia es demasiado pequeña, la mezcla de sonido requerirá considerablemente más CPU. Esto aumenta el riesgo de salto (una grieta en el sonido porque se perdió una llamada de mezcla).

Esta es una compensación común, por lo que Godot navega con defectos sensatos que no deberían ser alterados.

El problema, al final, no es este ligero retraso sino la sincronización de los gráficos y el audio para los juegos que lo requieren. A partir de Godot 3.2, se añadieron algunos ayudantes para obtener una sincronización de reproducción más precisa.

Usando el reloj del sistema para sincronizar

Como se mencionó antes, si llamas AudioStreamPlayer.play(), el sonido no comenzará inmediatamente, sino cuando el hilo de audio procese el siguiente trozo.

Este retraso no puede ser evitado pero puede ser estimado llamando AudioServer.get_time_to_next_mix().

La latencia de salida (lo que sucede después de la mezcla) también puede ser estimada llamando AudioServer.get_output_latency().

Añade estos dos y es posible adivinar casi exactamente cuando el sonido o la música comenzará a sonar en los altavoces durante el _process():

var time_begin
var time_delay


func _ready()
    time_begin = OS.get_ticks_usec()
    time_delay = AudioServer.get_time_to_next_mix() + AudioServer.get_output_latency()
    $Player.play()


func _process(delta):
    # Obtain from ticks.
    var time = (OS.get_ticks_usec() - time_begin) / 1000000.0
    # Compensate for latency.
    time -= time_delay
    # May be below 0 (did not begin yet).
    time = max(0, time)
    print("Time is: ", time)
private double _timeBegin;
private double _timeDelay;

public override void _Ready()
{
    _timeBegin = OS.GetTicksUsec();
    _timeDelay = AudioServer.GetTimeToNextMix() + AudioServer.GetOutputLatency();
    GetNode<AudioStreamPlayer>("Player").Play();
}

public override void _Process(float _delta)
{
    double time = (OS.GetTicksUsec() - _timeBegin) / 1000000.0d;
    time = Math.Max(0.0d, time - _timeDelay);
    GD.Print(string.Format("Time is: {0}", time));
}

A largo plazo, sin embargo, como el reloj de hardware de sonido nunca está exactamente sincronizado con el reloj del sistema, la información de tiempo se alejará lentamente.

Para un juego rítmico en el que una canción comienza y termina después de unos minutos, este enfoque está bien (y es el enfoque recomendado). Para un juego en el que la reproducción puede durar mucho más tiempo, el juego eventualmente se desincronizará y se necesitará un enfoque diferente.

Usando el reloj de hardware de sonido para sincronizar

Usando AudioStreamPlayer.get_playback_position() para obtener la posición actual de la canción suena ideal, pero no es tan útil tal cual. Este valor se incrementará en trozos (cada vez que la llamada de audio mezcló un bloque de sonido), por lo que muchas llamadas pueden devolver el mismo valor. Además, el valor estará fuera de sincronía con los altavoces también por las razones mencionadas anteriormente.

Para compensar la salida "chunked", hay una función que puede ayudar: AudioServer.get_time_since_last_mix().

Añadiendo el valor de retorno de esta función a get_playback_position() se aumenta la precisión:

var time = $Player.get_playback_position() + AudioServer.get_time_since_last_mix()
double time = GetNode<AudioStreamPlayer>("Player").GetPlaybackPosition() + AudioServer.GetTimeSinceLastMix();

Para aumentar la precisión, resta la información de latencia (cuánto tarda en escucharse el audio después de ser mezclado):

var time = $Player.get_playback_position() + AudioServer.get_time_since_last_mix() - AudioServer.get_output_latency()
double time = GetNode<AudioStreamPlayer>("Player").GetPlaybackPosition() + AudioServer.GetTimeSinceLastMix() - AudioServer.GetOutputLatency();

El resultado puede ser un poco tembloroso debido a la forma en que funcionan los múltiples hilos. Sólo comprueba que el valor no sea menor que en el fotograma anterior (descártalo si es así). También es un enfoque menos preciso que el anterior, pero funcionará para canciones de cualquier longitud, o sincronizando cualquier cosa (efectos de sonido, por ejemplo) con la música.

Aquí está el mismo código que antes de usar este enfoque:

func _ready()
    $Player.play()


func _process(delta):
    var time = $Player.get_playback_position() + AudioServer.get_time_since_last_mix()
    # Compensate for output latency.
    time -= AudioServer.get_output_latency()
    print("Time is: ", time)
public override void _Ready()
{
    GetNode<AudioStreamPlayer>("Player").Play();
}

public override void _Process(float _delta)
{
    double time = GetNode<AudioStreamPlayer>("Player").GetPlaybackPosition() + AudioServer.GetTimeSinceLastMix();
    // Compensate for output latency.
    time -= AudioServer.GetOutputLatency();
    GD.Print(string.Format("Time is: {0}", time));
}