Construire un monde ouvert dans le navigateur, partie 29 : un seul contrôleur, n’importe quel corps
Par Oleg Sidorkin, CTO et cofondateur de Cinevva
Vous découvrez la série ? Consultez le guide de la série. Il explique ce qu’est un spike et contient des liens vers toutes les parties.
La partie 28 portait sur l’herbe et l’occlusion. Vingt-huit parties ont permis de construire un monde dans lequel évoluer : un terrain lisible, de l’eau dans laquelle nager, une végétation qui tient jusqu’à l’horizon, un serveur qui mémorise vos modifications. Cette partie traite de ce qui se déplace à travers tout cela, et c’est l’aboutissement de l’ensemble du moteur, car l’objectif n’est pas de créer un contrôleur de joueur. Il s’agit de créer un contrôleur qui ne se soucie ni du corps qu’il pilote, ni de l’origine de ses animations, ni de savoir si un humain ou une IA est aux commandes. Le spike 58 construit le déplacement comme une pile de comportements enfichables au-dessus d’un moteur physique qui ne sait rien de la locomotion, puis valide l’architecture en faisant évoluer trois corps différents avec une même implémentation. Le spike 59 associe à ce contrôleur un véritable avatar recalé sans en modifier une seule ligne. Le spike 60 lui associe un autre pack d’animations qui ne nécessite aucun recalage et extrait la logique de sélection des clips pour la rendre véritablement testable.
Un moteur physique qui ne sait rien de la marche
Ouvrir le spike 58 dans un nouvel onglet ↗ · Voir le code source
La règle de conception est radicale, et c’est précisément tout l’intérêt : le moteur de capsule ne gère aucune locomotion. Ni marche, ni course, ni saut, ni friction, ni limite de vitesse maximale, pas même la gravité. Il intègre une capsule cinématique au terrain, rien de plus. Chaque comportement de locomotion — marche, glissade, vol plané, escalade, nage, accroupissement, endurance — vit dans un contrôleur autonome enregistré auprès du moteur. À chaque frame, le moteur actualise tous les contrôleurs, demande à chacun s’il souhaite prendre le contrôle, puis laisse le candidat ayant la priorité la plus élevée écrire la vélocité. La marche ne bénéficie d’aucun statut particulier. C’est simplement le contrôleur de priorité la plus basse qui répond toujours oui ; elle devient donc le comportement par défaut quand rien d’autre ne se déclenche. Un contrôleur se compose de six petites fonctions : un tick qui met à jour son propre état interne à chaque frame, même lorsqu’il n’est pas actif ; un prédicat pur wantsControl qui revendique la frame ; un applyForces, exécuté uniquement par le contrôleur gagnant, qui écrit la vélocité et applique sa propre gravité s’il en a besoin ; ainsi que les fonctions facultatives onEnter, onExit et stateName. La nage renvoie ownsCollision: true depuis applyForces afin de prendre en charge la gestion du terrain, car sinon son ressort de flottabilité entrerait en conflit avec l’alignement des pieds au sol du moteur.
Même les éléments qui ne relèvent pas de la locomotion finissent par transparaître à travers ce contrat, ce qui a imposé l’idée suivante : les canaux. Une première conception à canal unique faisait passer tous les comportements par un seul arbitrage. Un gestionnaire d’endurance ou une posture accroupie devait donc se faire passer pour de la locomotion, puis refuser le contrôle au moyen d’une astuce où wantsControl renvoyait faux, uniquement pour pouvoir effectuer sa comptabilité interne. La solution consiste à répartir les contrôleurs dans des canaux nommés qui effectuent leur arbitrage indépendamment et s’appliquent dans un ordre fixe : ressource, puis posture, puis locomotion. Le canal de ressource s’exécute en premier, car ses écritures, comme la diminution de l’endurance, sont lues par les autres. La posture s’exécute ensuite, car la réduction de la hauteur de la capsule lors de l’accroupissement doit être appliquée avant que la marche ne la lise pour limiter la vitesse maximale. La locomotion s’exécute en dernier et possède l’écriture de la vélocité pour la frame. Un observateur d’endurance, un modificateur d’accroupissement et un contrôleur de nage actif peuvent ainsi coexister proprement, chacun dans son propre canal, sans qu’aucun contrôleur ait à mentir sur sa nature. La démo représente tout cela au moyen d’une capsule nue dont la couleur varie selon le contrôleur actif, ce qui permet d’observer l’arbitrage : le vert de la marche passe à l’orange sur une pente raide lorsque la glissade prend le relais, au cyan dans le lac lorsque la nage l’emporte, puis au crème dans les airs quand vous déclenchez le vol plané.
Un seul moteur, trois corps
Le véritable test d’un moteur qui « ne gère aucune locomotion » n’est pas le joueur. Il faut vérifier si le même moteur, sans aucune modification, peut piloter quelque chose qui n’est pas du tout un joueur. createCapsuleEngine est une fabrique pure, sans état au niveau du module, sans singleton et sans effets secondaires propres à chaque instance. Le spike l’instancie donc trois fois. Le joueur est une première instance dotée de l’ensemble complet des contrôleurs. Un cheval montable constitue une deuxième instance qui n’enregistre qu’un contrôleur de marche, rendant ainsi toute l’idée littérale : le vocabulaire de déplacement d’un corps correspond simplement aux contrôleurs qui ont été enregistrés. Le cheval est donc plus rapide sur terrain plat et ne peut physiquement ni escalader, ni nager, ni planer, car ces contrôleurs n’ont jamais été ajoutés. Un PNJ autonome constitue une troisième instance, actualisée à chaque frame en même temps que le joueur et pilotée par un contrôleur d’errance qui synthétise ses propres entrées afin que le corps se dirige seul, sans aucune intervention au clavier. Le moteur n’apprend jamais que l’un de ses corps est un cheval ou qu’un autre est piloté par une IA. Tous utilisent le même intégrateur de capsule, avec des listes de contrôleurs différentes.
La monture est le seul élément qui vit délibérément en dehors du moteur. Transférer le contrôle entre le joueur et le cheval nécessite de coordonner deux moteurs. Or, un contrôleur s’exécute au sein d’un seul moteur et ne peut pas voir au-delà de cette frontière. La logique de monture se situe donc au niveau de l’hôte : il s’agit d’une petite machine à états qui décide quel moteur est exécuté pendant cette frame, immobilise l’autre, amène progressivement le cavalier sur la selle au moyen d’un smoothstep sur trois quarts de seconde et indique à la caméra quel corps suivre. La fabrique du moteur n’entend jamais parler de tout cela. C’est la frontière que l’architecture trace et respecte. Les comportements propres à un seul corps sont des contrôleurs ; la coordination entre plusieurs corps relève de l’hôte. C’est cette séparation qui permettrait d’ajouter un quatrième ou un quarantième corps sans aucun coût architectural supplémentaire.
Testé sans navigateur
Comme le moteur n’accède ni à window, ni à document, ni à Three.js, l’ensemble fonctionne sans interface graphique. Le spike fournit un banc de test Node qui simule l’interface du terrain, pilote le moteur frame par frame avec des entrées scriptées et vérifie l’état obtenu. Les régressions particulièrement pénibles à détecter par des sessions de jeu sont ainsi repérées par un script : transition entre saut et atterrissage, seuils d’entrée et de sortie de l’eau, désactivation automatique de l’escalade lorsqu’une surface s’aplatit, rythme de diminution de l’endurance. Exécuter deux fois la même séquence d’entrées et de pas de temps, puis vérifier que l’état de sortie est identique octet par octet, garantit le déterminisme du moteur, propriété sur laquelle une future version en réseau finira par s’appuyer. Une régression détectée par le banc de test mérite d’être conservée : passer d’un sol praticable à une pente trop raide pour être parcourue déclenchait auparavant le contrôleur de glissade et faisait rebondir le joueur vers le haut de la pente. La correction a consisté à ajouter une condition de descente. La glissade ne démarre que si la capsule tombe effectivement sur la pente. Marcher horizontalement contre une paroi abrupte bloque désormais proprement le joueur au lieu de le faire glisser, et le test qui vérifie que « marcher contre une pente non praticable bloque le joueur » garantit que cela reste corrigé.
Brancher un véritable avatar sans toucher aux contrôleurs
Ouvrir le spike 59 dans un nouvel onglet ↗ · Voir le code source
Le spike 59 vérifie si la couche des contrôleurs est réellement découplée du corps. Il remplace la capsule colorée par un véritable personnage skinné — le rig FBX 3MIKE, accompagné de clips de la Quaternius Universal Animation Library recalés sur celui-ci — sans modifier les contrôleurs. La jonction se résume à une simple chaîne de caractères. Chaque contrôleur communique déjà un nom d’état par l’intermédiaire de stateName — idle, walk, run, jump, fall, land, slide, glide, swim, swimIdle — et la couche de l’avatar associe ce nom à un clip recalé au moyen d’une table d’alias, puis effectue un fondu enchaîné lors du changement. Le contrôleur de marche sélectionne dynamiquement son propre sous-état à partir du contact au sol, de la vélocité verticale et de la vitesse horizontale. Un seul contrôleur de marche pilote ainsi l’attente, la marche, la course, le saut, la chute et l’atterrissage, tandis que l’avatar se contente de suivre le nom communiqué. Comme ce spike est conçu pour un seul joueur, l’avatar abandonne l’indirection par entiers utilisée sur le réseau et l’abstraction multi-personnages des précédents spikes en réseau, pour associer directement le nom de l’état à un clip. Le fait que toute cette amélioration visuelle — le passage d’une capsule à un humain doté d’un rig — n’ait modifié aucune ligne du code de locomotion démontre précisément l’intérêt d’un contrôleur enfichable.
Un pack sans recalage et un sélecteur testable
Ouvrir le spike 60 dans un nouvel onglet ↗ · Voir le code source
Le spike 60 branche un troisième corps, le pack POLYGON Base Locomotion de Synty, et son chargeur est presque inexistant. Chaque clip est fourni sous la forme d’un fichier FBX autonome contenant une copie intégrée du même squelette Synty ainsi qu’une animation précalculée. Comme le rig du personnage et chaque clip utilisent des noms d’os identiques, il suffit de récupérer le clip dans fbx.animations[0] et de le lire directement dans le mixer du personnage, sans aucune bibliothèque de recalage. Three.js résout les cibles des pistes d’animation à partir du nom des os plutôt que de l’identité des objets. Un pack de type Synty ou Mixamo créé pour le rig correspondant fonctionne donc directement. C’est un contraste délibéré avec le parcours UAL du spike précédent, qui nécessite un recalage conséquent parce que les clips sources et le rig cible ont été créés pour des squelettes différents. Même contrôleur, même jonction par nom d’état, mais deux pipelines d’animation totalement différents derrière celle-ci.
L’autre moitié du spike 60 consiste à rendre testable la logique de sélection des clips. Choisir quel clip lire repose sur de nombreux seuils d’appréciation, et cette logique était auparavant enfouie dans la couche de l’avatar, à côté de FBXLoader et du DOM, où elle ne pouvait pas être testée. Le spike extrait le sélecteur dans des fonctions pures qui n’accèdent ni à Three.js ni à la fenêtre : elles reçoivent un simple enregistrement du joueur — vélocité, vitesse horizontale, orientation, contact au sol, normale du sol, vélocité d’impact — ainsi qu’un substitut d’action de clip, puis renvoient une chaîne d’alias de clip. Les seuils peuvent ainsi devenir des constantes nommées et couvertes par des assertions. Le saut se décline en variantes de marche, de course et de sprint selon des tranches de vitesse alignées sur le véritable seuil de sprint du contrôleur de marche ; les atterrissages se divisent en variantes douces, moyennes et brutales selon la vélocité d’impact ; les variantes de clips en montée et en descente se déclenchent à partir d’un produit scalaire de projection sur la pente, avec une zone morte qui utilise le clip de terrain plat en dessous d’environ sept degrés ; une transition entre l’attente et la locomotion choisit toujours la direction avant, afin qu’un départ à l’arrêt ne lise jamais un clip à l’envers ; et une transition d’arrêt est soumise à une durée minimale passée dans la boucle, car les clips d’arrêt alignés sur la phase des pieds de Synty contiennent environ une seconde de décélération prédéfinie, ce qui paraît ridicule lorsqu’elle est ajoutée à un mouvement où le joueur a à peine fait un pas. Un petit anti-rebond d’état retarde l’état de chute de quelques frames, afin qu’une perte de contact avec le sol pendant une seule frame au passage d’une jonction ne fasse pas clignoter l’animation. Extraire toutes ces règles de l’enveloppe de rendu permet de les tester unitairement sans GPU, en appliquant la même discipline d’exécution sans interface graphique que celle adoptée pour le moteur dans le spike 58. C’est ce qui distingue des sensations de locomotion réglées au hasard de sensations que l’on peut définir et stabiliser précisément.
Technologies abordées dans ce chapitre
Un moteur physique dépourvu de locomotion. Le moteur de capsule intègre un corps cinématique au terrain et ne gère ni la marche, ni la course, ni le saut, ni la friction, ni la limite de vitesse, ni la gravité. Chaque comportement est un contrôleur enregistré qui expose tick, wantsControl, applyForces, ainsi que les fonctions facultatives onEnter/onExit/stateName. La marche n’est que le comportement par défaut de priorité minimale qui répond toujours oui, et un contrôleur peut renvoyer ownsCollision: true pour prendre en charge la gestion du terrain (c’est le cas de la nage, afin que son ressort de flottabilité n’entre pas en conflit avec l’alignement des pieds au sol).
Des canaux d’arbitrage indépendants. Les contrôleurs sont enregistrés dans des canaux nommés (ressource, posture, locomotion) qui effectuent leur arbitrage séparément et s’appliquent dans un ordre fixe. Les observateurs comme l’endurance et les modificateurs comme l’accroupissement coexistent ainsi avec la locomotion active au lieu de simuler une prise de contrôle pour ensuite la refuser. Les écritures du canal de ressource (endurance) sont lues par la posture et la locomotion ; les écritures de la posture (hauteur de la capsule accroupie) sont lues par la limite de vitesse de la locomotion ; la locomotion s’exécute en dernier et possède l’écriture de la vélocité. Une seule fabrique, de nombreux corps. createCapsuleEngine est une fabrique pure sans singleton : le même moteur anime donc le joueur, un cheval montable limité à la marche et un PNJ autonome alimenté par des entrées synthétiques. Le vocabulaire de déplacement d’un corps correspond exactement à l’ensemble de ses contrôleurs enregistrés. Le cheval ne peut donc ni grimper ni nager, puisque ces contrôleurs n’ont jamais été ajoutés. La gestion de la monture se situe au niveau de l’hôte, et non dans un contrôleur, car elle coordonne deux moteurs qui ne peuvent pas communiquer directement. Voir le LOD piloté par le GPU.
Tests déterministes sans interface graphique. Le moteur n’accède ni à window, ni à document, ni à Three.js. Un banc de test Node peut donc le piloter image par image et vérifier les séquences de saut et d’atterrissage, les seuils de nage, la sortie automatique de l’escalade et la consommation d’endurance. Rejouer deux fois des entrées identiques et vérifier que les sorties le sont aussi démontre le déterminisme. Un test de régression verrouille également le seuil de descente qui empêche le joueur de glisser en arrière lorsqu’il marche horizontalement contre une paroi abrupte.
Liaison d’avatar indépendante du corps, avec un sélecteur testable. Les contrôleurs renvoient le nom d’un état sous forme de chaîne, puis la couche visuelle l’associe à un clip. Remplacer une capsule par un avatar 3MIKE + UAL reciblé ne nécessite donc aucune modification du code de locomotion. Les clips Synty POLYGON partagent les noms d’os du rig et se lisent sans reciblage — Three.js associe les pistes selon le nom des os — contrairement au lourd processus de reciblage de la voie UAL. Le sélecteur de clips est extrait sous forme de fonctions pures, avec des constantes nommées pour les paliers de vitesse de saut, la sévérité de l’atterrissage, les variantes de projection sur les pentes, une transition depuis l’immobilité toujours orientée vers l’avant, une durée minimale de protection pour la transition d’arrêt et un filtre anti-rebond pour l’état de chute. Les sensations de locomotion peuvent ainsi être testées unitairement plutôt que réglées au hasard.
Partie 29 sur 30. Précédent : Partie 28 — De l’herbe jusqu’à l’horizon et un sol qui se dissimule Suivant : Partie 30 — Une caméra qui respecte les murs Guide de la série : /blog/2026-02-25-open-world-browser-series-guide