
O post anterior, sobre engadir voces aos personaxes en Godot quedou inconcluso, ao deixar para máis adiante as optimizacións necesarias para executalo nun móbil de gama baixa.
Xa dende a propia documentación de GDXFR contraindican o uso do plugin como expresei no artigo anterior. A razón é que, dados uns parámetros iniciais, a xeración do son completo faise de unha vez, impedindo a execución de calquera outro pedazo de script ata a fin. Dependendo do tamaño do audio, por tanto, o tempo de espera vai variar.
Unha alternativa inexplorada por agora
Habería unha solución tan boa como a que vou expoñer aquí, que sería dividir a xeración do audio en pequenas partes e só xerar o necesario para irse escoitando no momento. Os sintetizadores de audio funcionan deste xeito, xa que o son que se escoitará depende da interpretación do músico e canto máis tempo teñen de son xerado máis retardo se notará entre presionar unha tecla e escoitar o resultado.
Xa que xerar o son en pequenas partes requeriría moitos cambios no código do plugin e hoxe en día incluso os móbiles de máis baixa gama teñen dous ou máis núcleos decidinme a utilizar un deles para a xeración do audio, liberando o principal para a execución do xogo.
Fíos de procesamento
Como dicía, case calquera procesador moderno se compón de dous núcleos ou máis. Isto ten que ver con que é máis sinxelo engadir núcleos a aumentar a velocidade. Isto é aínda máis importante en casos como os teléfonos móbiles, onde prima un consumo baixo de batería. Así e todo, o dobre de núcleos non implica un aumento da velocidade a menos que se faga bo uso deles.
Para isto están os fíos de procesamento, máis coñecidos como threads. Cando engadimos un novo thread ao noso executable, estamos permitindo ao sistema operativo executar código en paralelo reutilizando a mesma memoria e recursos do proceso existente. Isto ten de bo que é moi rápido de comezar un novo fío de execución, comparado con comezar un novo executable, e que non se precisa de copiar a memoria entre un fío e outro, xa que se comparte toda.
Por outra banda, ao compartirse toda a memoria é moi fácil causar erros, ao modificar nun fío memoria que se está a ler no outro por exemplo. Xestionar isto con coidado é bastante complexo e, aínda que neste artigo abordarei un modo bastante simple de levalo a cabo, diferentes necesidades van a ter solucións moi distintas.
Programación asíncrona
Antes de explicar a solución ao problema da xeración de audio usando outro núcleo de procesamento, creo que é importante explicar a diferenza entre crear distintos fíos e utilizar a programación asíncrona en Godot.
A programación asíncrona tamén nos permite continuar executando o xogo mentres hai procesos longos en paralelo, como ler un arquivo. Deste xeito, no canto de esperar a que a lectura remate, o código continúa executando outras funcións e retorna cando a lectura remata. Iso é posible porque no caso da lectura dun arquivo é o sistema operativo o que se encarga de xestionar ese proceso e logo notificar a Godot da súa finalización.
A razón pola que non sería unha solución viable para este caso é que a xeración do audio tamén ocorre dende o script de Godot. A programación asíncrona ocorre nun só fío de procesamento, polo que ao estar este ocupado na xeración de audio non quedaría tempo a ningún outro procesamento.
Produtor/Consumidor
Para recordar o problema, o que aquí precisamos é xerar audio para logo ser reproducido. A solución é mover a xeración dese audio a un fío de procesamento distinto para que o sistema o poda executar nun núcleo ocioso do noso procesador.
Este tipo de problemas adoitan modelarse definindo o produtor da nosa información e o consumidor. Está claro cal é cal neste sistema, pero hai detalles que debemos definir correctamente. O primeiro é que o consumidor vai esperar a que o son estea finalizado, polo que necesitamos crealo por adiantado.
Unha opción sería crear un novo fío por cada audio que necesitemos e finalizalo cando estea listo. Non só sería unha opción complexa de xestionar pero por riba comezar e finalizar fíos de procesamento, por lixeiros que sexan, ten o seu custo asociado.
Por isto, cando unha nova conversa comeza, crease un novo fío e, ao saír, péchase.
var thread_prepare_audio: Thread
var producing = false
func _ready():
thread_prepare_audio = Thread.new()
thread_prepare_audio.start(audio_prepare_func)
func _exit_tree():
producing = false
thread_prepare_audio.wait_to_finish()
func audio_prepare_func():
producing = true
while producing:
if !audio_generated:
# xeramos o audio
else:
OS.delay_msec(10)
Con este código teriamos o suficiente para comezar o novo thread e pechalo de maneira adecuada nas funcións por defecto do script, _ready
e _exit_tree
. Pode parecer ineficiente que exista un bucle constante, pero ao “durmir” coa función delay_msec
sempre que non se necesite máis audio o seu custo será case imperceptible.
Para que o consumidor espere a que exista ao menos un audio na lista, utilicei un semáforo. Este é un tipo de obxecto que se pode utilizar de xeito seguro entre distintos fíos e contén un número que se pode aumentar con post
e diminuir con wait
. No caso de que o número sexa 0 na chamada a wait
, esta bloqueará ata que se faga post
dende outro thread. Co seguinte exemplo creo que se entende mellor:
var consume_semaphore: Semaphore
func audio_prepare_func():
producing = true
while producing:
if !audio_generated:
# xeramos audio
consume_semaphore.post()
else:
OS.delay_msec(10)
func get_prepared_audio() -> AudioStreamWAV:
consume_semaphore.wait()
# collemos o audio producido
return audio
Deste xeito, cando se chame a get_prepared_audio
, esta función esperará a que audio_prepare_func teña algo xerado e o notifique a través do semáforo.
Xa vimos como se xera o son no artigo anterior así que aquí só me centrarei na comunicación entre o produtor e o consumidor. Xa que a memoria de ambos fíos é a mesma, basta con ter unha lista que conteña os audios xerados para envialos entre si. Temos que ter unha maneira de que esta lista non sexa accedida por máis que un fío á vez. O semáforo só comproba que hai abondos audios, pero non impide que no mesmo momento se acceda á lista tanto para engadir un novo como para coller outro.
Os mutex son un tipo de semáforo que só pode estar activado ou desactivado. O seu nome é un acrónimo do termo inglés para exclusión mutua. E asegura que mentres está activo ningunha outra liña de execución poderá entrar. Para o noso caso será suficiente con protexer con mutex todos os accesos á nosa lista de audios:
var prepared_audio_list = []
var prepared_audio_mutex: Mutex
var thread_prepare_audio: Thread
var consume_semaphore: Semaphore
func _ready():
thread_prepare_audio = Thread.new()
prepared_audio_mutex = Mutex.new()
consume_semaphore = Semaphore.new()
thread_prepare_audio.start(audio_prepare_func)
func audio_prepare_func():
producing = true
var num_audios = 0
while producing:
prepared_audio_mutex.lock()
num_audios = prepared_audio_list.size()
prepared_audio_mutex.unlock()
if num_audios < 3:
# xeramos audio
prepared_audio_mutex.lock()
prepared_audio_list.push_back(audio)
prepared_audio_mutex.unlock()
consume_semaphore.post()
else:
OS.delay_msec(10)
func get_prepared_audio() -> AudioStreamWAV:
consume_semaphore.wait()
prepared_audio_mutex.lock()
audio = prepared_audio_list.pop_front()
prepared_audio_mutex.unlock()
return audio
Visto en conxunto, o código pode parecer bastante complexo, pero o único engadido agora for rodear de lock
e unlock
cada interacción coa lista de audios. Tamén aqui se ve explícito cando se xera un audio novo, neste caso cando hai menos de 3 elementos na lista. Isto é totalmente arbitrario e pode ser tan baixo como 1.
Conclusión
Utilizar varios núcleos pode resultar complexo, pero creo que este artigo pode ser un bo punto de partida para intentar resolver outros moitos problemas con Godot.
Quedoume algo máis longo do que me gustaría, pero había bastantes detalles que explicar. De todos os xeitos, os comentarios están abertos para calquera dúbida.