Aller au contenu principal

Intégration du PlayerLoop Unity

ValkarnTasks n'utilise pas de threads ni de planificateurs en arrière-plan pour reprendre vos méthodes async. Tout s'exécute sur le thread principal d'Unity, piloté par le système PlayerLoop d'Unity lui-même. Comprendre comment cela fonctionne vous aidera à choisir le bon timing pour votre cas d'usage et à raisonner sur le moment exact où votre code reprend après un await.

Qu'est-ce que le PlayerLoop Unity

Le PlayerLoop Unity est la boucle interne du moteur qui pilote chaque frame. Ce n'est pas un simple appel Update() — c'est une séquence hiérarchique de phases qui s'exécutent dans un ordre défini à chaque frame :

Initialization
EarlyUpdate
FixedUpdate (répété si la physique progresse)
PreUpdate
Update
PreLateUpdate
PostLateUpdate
TimeUpdate

Chacune de ces phases de premier niveau contient des sous-systèmes qu'Unity (et les packages tiers) insèrent pour exécuter leur logique à des points spécifiques. MonoBehaviour.Update(), par exemple, s'exécute dans la phase Update. MonoBehaviour.LateUpdate() s'exécute dans PreLateUpdate.

Parce que ValkarnTasks se branche directement dans cette boucle, await ValkarnTask.Yield() ne bloque pas un thread — il enregistre un callback qu'Unity appelle au prochain tick de la phase choisie, puis retourne immédiatement.

Les 16 Timings PlayerLoop

ValkarnTasks s'injecte en 16 points : un au début et un à la fin de chacune des 8 phases du PlayerLoop Unity. Les variantes Last sont ajoutées à la fin de la liste de sous-systèmes de leur phase parente ; les variantes simples sont ajoutées au début.

ValeurEntier de l'enumPhase parentePosition dans le parent
Initialization0UnityEngine.PlayerLoop.InitializationPremier
LastInitialization1UnityEngine.PlayerLoop.InitializationDernier
EarlyUpdate2UnityEngine.PlayerLoop.EarlyUpdatePremier
LastEarlyUpdate3UnityEngine.PlayerLoop.EarlyUpdateDernier
FixedUpdate4UnityEngine.PlayerLoop.FixedUpdatePremier
LastFixedUpdate5UnityEngine.PlayerLoop.FixedUpdateDernier
PreUpdate6UnityEngine.PlayerLoop.PreUpdatePremier
LastPreUpdate7UnityEngine.PlayerLoop.PreUpdateDernier
Update8UnityEngine.PlayerLoop.UpdatePremier
LastUpdate9UnityEngine.PlayerLoop.UpdateDernier
PreLateUpdate10UnityEngine.PlayerLoop.PreLateUpdatePremier
LastPreLateUpdate11UnityEngine.PlayerLoop.PreLateUpdateDernier
PostLateUpdate12UnityEngine.PlayerLoop.PostLateUpdatePremier
LastPostLateUpdate13UnityEngine.PlayerLoop.PostLateUpdateDernier
TimeUpdate14UnityEngine.PlayerLoop.TimeUpdatePremier
LastTimeUpdate15UnityEngine.PlayerLoop.TimeUpdateDernier

Update (valeur 8) est le timing par défaut pour toutes les opérations ValkarnTasks — Yield(), Delay(), WaitUntil(), WaitWhile(), NextFrame(), et DelayFrame().

Structs marqueurs et injection idempotente

Pour s'injecter dans le PlayerLoop Unity, ValkarnTasks crée un PlayerLoopSystem par timing. Chaque système est identifié par une struct marqueur unique définie dans PlayerLoopHelper :

struct ValkarnTaskInitialization     { }
struct ValkarnTaskLastInitialization { }
struct ValkarnTaskEarlyUpdate { }
struct ValkarnTaskLastEarlyUpdate { }
struct ValkarnTaskFixedUpdate { }
struct ValkarnTaskLastFixedUpdate { }
struct ValkarnTaskPreUpdate { }
struct ValkarnTaskLastPreUpdate { }
struct ValkarnTaskUpdate { }
struct ValkarnTaskLastUpdate { }
struct ValkarnTaskPreLateUpdate { }
struct ValkarnTaskLastPreLateUpdate { }
struct ValkarnTaskPostLateUpdate { }
struct ValkarnTaskLastPostLateUpdate { }
struct ValkarnTaskTimeUpdate { }
struct ValkarnTaskLastTimeUpdate { }

Avant l'injection, PlayerLoopHelper vérifie si ValkarnTaskUpdate apparaît déjà quelque part dans l'arbre PlayerLoop actuel. Si c'est le cas, l'injection est ignorée. Cela rend l'injection idempotente — appeler Init() plusieurs fois (ce qui peut arriver dans l'éditeur) ne résulte jamais en des systèmes dupliqués enregistrés.

L'injection lit toujours le PlayerLoop actuel avec PlayerLoop.GetCurrentPlayerLoop(), jamais GetDefaultPlayerLoop(). Cela signifie que tous les systèmes précédemment installés par d'autres packages sont préservés.

ContinuationQueue — Callbacks à usage unique

Quand vous faites await ValkarnTask.Yield() (ou quelque chose qui se suspend pour exactement un tick), la machine d'état générée par le compilateur appelle OnCompleted sur l'awaiter. L'awaiter appelle PlayerLoopHelper.AddContinuation(timing, action, state), qui met en file d'attente le callback dans la ContinuationQueue pour ce timing.

ContinuationQueue utilise une conception à double tampon :

  1. Tampon actif (actionList) : contient les continuations mises en file avant et pendant le drain actuel.
  2. Tampon d'attente (waitingList) : capture les continuations mises en file pendant que le tampon actif est drainé (c'est-à-dire les mises en file réentrantes depuis une continuation elle-même).
  3. Pile Treiber cross-thread (crossThreadHead) : les continuations postées depuis des threads en arrière-plan atterrissent ici via un compare-and-swap sans verrou. Au début de chaque drain, la pile entière est atomiquement réclamée et déplacée dans le tampon actif.

À chaque tick PlayerLoop, ContinuationQueue.Run() exécute cette séquence :

1. Drainer crossThreadHead → actionList     (prise atomique sans verrou, puis copie sous SpinLock)
2. Capturer le compte, définir isDraining = true (sous SpinLock)
3. Exécuter tous les callbacks (hors verrou, refs effacées pour le GC)
4. Échanger actionList ↔ waitingList (sous SpinLock, définir isDraining = false)

Après le préchauffage d'environ une ou deux frames, les tableaux internes se stabilisent à leur niveau maximal et la file d'attente fonctionne avec zéro allocation.

Les nœuds cross-thread sont mis en pool dans un pool sans verrou borné (max 1 024 nœuds) pour éviter d'allouer à chaque mise en file d'un thread en arrière-plan.

PlayerLoopRunner — Éléments récurrents

Certaines opérations doivent être vérifiées à chaque tick jusqu'à leur completion : Delay(), DelayFrame(), WaitUntil(), WaitWhile(), et NextFrame(). Celles-ci implémentent l'interface IPlayerLoopItem :

internal interface IPlayerLoopItem
{
// Retourner true pour continuer à s'exécuter ; retourner false pour se retirer du runner.
bool MoveNext();
}

Les éléments sont ajoutés à PlayerLoopRunner via PlayerLoopHelper.AddAction(timing, item). À chaque tick, PlayerLoopRunner.Run() itère tous les éléments enregistrés et appelle MoveNext() sur chacun. Les éléments qui retournent false sont retirés par compaction sur place — le tableau est compacté en un seul passage, préservant l'ordre d'insertion. Les éléments ajoutés pendant un appel Run() sont ajoutés en toute sécurité et seront pris en compte au tick suivant.

Tick N :
ContinuationQueue.Run() → reprendre tous les awaiters à usage unique
PlayerLoopRunner.Run() → ticker tous les éléments récurrents (Delay, WaitUntil, ...)

Les deux s'exécutent pour chacun des 16 timings, dans l'ordre où le moteur les appelle.

Le timing Update suit également le nombre global de frames et déclenche périodiquement la réduction du pool d'objets (tous les ValkarnTask.TrimCheckInterval frames).

Initialisation — RuntimeInitializeOnLoadMethod

ValkarnTasks s'initialise à RuntimeInitializeLoadType.SubsystemRegistration, le hook le plus précoce qu'Unity fournit, qui se déclenche avant Awake() sur tout objet de scène :

[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
static void Init()
{
// 1. Capturer l'ID du thread principal pour les vérifications de thread-safety
// 2. Créer ContinuationQueue et PlayerLoopRunner pour les 16 timings
// 3. S'injecter dans le PlayerLoop (idempotent)
// 4. Enregistrer le callback de changement d'état du mode play (éditeur uniquement)
}

L'ID du thread principal est capturé à ce point et partagé avec tous les sous-systèmes de pool et de file d'attente afin qu'ils puissent distinguer les mises en file du thread principal (chemin SpinLock) des mises en file cross-thread (chemin pile Treiber).

Gestion du rechargement de domaine (Éditeur)

Dans l'éditeur Unity, entrer ou quitter le mode Play déclenche un rechargement de domaine. L'état statique de la session play précédente persisterait autrement et causerait des références périmées.

ValkarnTasks gère cela avec un callback EditorApplication.playModeStateChanged enregistré pendant Init(). Quand l'état passe à ExitingPlayMode ou EnteredEditMode, le nettoyage suivant s'exécute :

// Réinitialiser toutes les files d'attente et runners
for (int i = 0; i < 16; i++)
{
s_continuationQueues[i] = null;
s_playerLoopRunners[i] = null;
}

// Réinitialiser les autres sous-systèmes
ValkarnTask.ResetStatics();
TimeProvider.ResetToDefault(); // revient à UnityTimeProvider
PoolRegistry.Clear();
ContinuationQueue.ContinuationNode.ResetPool();

Quand le mode Play est à nouveau entré, Init() se déclenche via RuntimeInitializeOnLoadMethod et de nouvelles files d'attente et runners sont alloués. La vérification d'injection (HasValkarnTaskSystems) empêche les systèmes marqueurs d'être insérés une deuxième fois si le domaine n'a pas été entièrement déchargé.

Choisir le bon timing

La plupart du code devrait utiliser le timing Update par défaut. Ne recourez aux autres que lorsque vous avez une raison spécifique.

TimingQuand l'utiliser
Initialization / LastInitializationConfiguration très précoce de frame ; rarement nécessaire dans le code de jeu
EarlyUpdate / LastEarlyUpdateÉchantillonnage d'entrée ; s'exécute avant la physique et avant Update
FixedUpdate / LastFixedUpdateLogique synchronisée avec la physique ; correspond au rythme de MonoBehaviour.FixedUpdate
PreUpdate / LastPreUpdateAvant la distribution principale de mise à jour Unity ; utile pour une pré-passe de planificateur personnalisé
Update (par défaut)Logique de jeu standard ; correspond à MonoBehaviour.Update
LastUpdateLogique qui doit s'exécuter après tous les appels MonoBehaviour.Update
PreLateUpdate / LastPreLateUpdateCorrespond à MonoBehaviour.LateUpdate ; suivi de caméra et de transform
PostLateUpdate / LastPostLateUpdateAprès la soumission du rendu ; finalisation UI, capture d'écran
TimeUpdate / LastTimeUpdateSynchronisation des valeurs de temps ; très rarement nécessaire
// Reprendre au prochain tick Update (par défaut)
await ValkarnTask.Yield();

// Reprendre au début du prochain FixedUpdate (sûr pour la physique)
await ValkarnTask.Yield(PlayerLoopTiming.FixedUpdate);

// Attendre 2 secondes, avançant avec le temps non échelonné, vérifié dans LateUpdate
await ValkarnTask.Delay(
TimeSpan.FromSeconds(2),
DelayType.UnscaledDeltaTime,
PlayerLoopTiming.PreLateUpdate);

// Attendre jusqu'à ce qu'une condition soit vraie, vérifiée après tous les appels LateUpdate
await ValkarnTask.WaitUntil(() => _ready, PlayerLoopTiming.LastPreLateUpdate);