Skip to content

Construire un monde ouvert dans le navigateur, partie 30 : une caméra qui respecte les murs

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 prototype exploratoire et fournit les liens vers toutes les parties.

La partie 29 nous a donné un contrôleur unique capable de piloter n'importe quel corps. Le corps se déplace désormais correctement. Il marche, glisse, nage, plane, grimpe dans les grottes et se baisse sous les surplombs. Le problème vient de ce qui l'observe. Pendant vingt-neuf parties, la caméra était un système orbital standard qui suivait le joueur et ne faisait qu'une seule chose astucieuse pour éviter les situations embarrassantes : elle refusait de s'incliner sous l'horizon afin de ne pas pouvoir passer sous un sol plat. Cette limite est révélatrice. Elle existe parce que la caméra ignorait totalement où se trouvait réellement la géométrie du monde ; la seule défense contre le clipping consistait donc à interdire les angles où il risquait le plus de se produire. Approchez-vous d'une colline et la caméra se retrouvait à l'intérieur. Entrez dans l'une des grottes en marching cubes de la partie 7 et vous contempliez l'intérieur d'un rocher. Construisez une maison avec les outils de création de la partie 16, placez-vous dans une pièce, et la caméra flottait de l'autre côté du mur en cadrant le bardage. Cette partie apprend à la caméra à respecter la géométrie autant que le corps le fait déjà.

La forme du problème et celle de la solution

Une caméra à la troisième personne a une tâche difficile et une douzaine de tâches faciles. Les plus simples sont le suivi, le lissage et le contrôle orbital, que nous avions déjà. La tâche difficile correspond à ce que la littérature universitaire appelle une contrainte de visibilité. L'étude que tout le monde cite, Camera Control in Computer Graphics de Christie et Olivier, structure tout le domaine autour de cette idée : garder le sujet dans le cadre et visible tout en respectant le monde. À l'exécution, cela se résume à une question d'une simplicité trompeuse, posée à chaque image. Le joueur est le pivot. L'utilisateur a fait pivoter la caméra et réglé le zoom jusqu'à une position souhaitée, à une certaine distance derrière le joueur. Jusqu'où la caméra peut-elle réellement reculer le long de cette ligne avant de pénétrer dans un élément solide ? Répondez honnêtement à cette question et la caméra se replie devant la colline, se rapproche le long de la perche lorsque vous reculez dans un coin et s'arrête au plafond de la grotte au lieu de le traverser.

Le modèle qui résout ce problème est ancien et éprouvé. Unreal l'appelle un bras à ressort, Godot fournit un nœud SpringArm3D et Cinemachine de Unity répartit cette fonction entre un système de suivi à la troisième personne et une extension de désoccultation. L'idée reste toujours la même. La caméra est suspendue à l'extrémité d'une perche ancrée au pivot. Elle reste à la longueur souhaitée lorsque la trajectoire est dégagée, se rétracte vers le pivot lorsqu'un obstacle gêne le passage et revient progressivement à sa position lorsque la voie se libère. Dans Real-Time Cameras, Mark Haigh-Hutchinson, responsable des caméras de Metroid Prime, consacre des chapitres entiers aux modes de défaillance qui transforment une implémentation naïve en machine à donner la nausée aux joueurs. Nous avons repris ce modèle pour créer notre propre système, suffisamment petit pour être lu d'une traite, dans public/world/src/camera-rig.mjs.

murpivot (tête du joueur)position souhaitée (zoom utilisateur)caméra + rayon de sonde rℓ (limitée)
La perche maintient la caméra au niveau de zoom souhaité par l'utilisateur jusqu'à ce qu'un obstacle s'interpose. Elle se rétracte alors le long du même axe afin que la sphère-sonde repose contre la surface au lieu de laisser le plan proche la traverser.

Une perche qui ne gère que sa longueur

La règle de conception qui nous a permis de garder ce système compact vient du travail sur le contrôleur de la partie 29 : gérer entièrement une seule chose et refuser de s'occuper du reste. Le système gère la longueur de la perche, rien d'autre. Le lacet, le tangage, l'amortissement de l'orbite, les gestes tactiles et la molette restent tous sous la responsabilité d'OrbitControls, qui s'en charge déjà très bien et que nous n'avons aucune envie de réécrire. Notre système n'est donc pas un contrôleur de caméra. C'est un post-traitement exécuté après les calculs orbitaux qui ne corrige qu'une seule valeur : la distance entre le pivot et la caméra.

Cette décision paraît élégante, mais elle a failli tout casser immédiatement à cause de la logique interne d'OrbitControls. Au début de chaque mise à jour, OrbitControls lit la position actuelle de la caméra et en déduit son rayon orbital. Ce comportement est normalement invisible. Mais dès que notre système rapproche la caméra pour éviter un mur, OrbitControls lit cette position raccourcie à l'image suivante, en conclut que l'utilisateur a dû zoomer et intègre ce raccourcissement au zoom souhaité. Après quelques images, la caméra s'est effondrée sur la tête du joueur et refuse de reprendre sa place. La solution tient en deux appels qui encadrent la mise à jour orbitale, et c'est toute l'intégration. Avant l'exécution d'OrbitControls, beforeControls() replace la caméra à la distance totale non raccourcie de l'image précédente, afin que les calculs orbitaux lisent toujours le véritable zoom de l'utilisateur. Après l'exécution d'OrbitControls, afterControls(dt) lit la nouvelle position souhaitée calculée par l'orbite, résout la collision, amortit la longueur et place la caméra là où elle doit réellement effectuer le rendu. L'intention de l'utilisateur et la correction des collisions n'interfèrent jamais, et le travelling conserve exactement la même réactivité qu'avant l'ajout du système. Le test sans interface graphique que nous avons écrit pour ce système vérifie précisément ce comportement : poussez la perche contre un mur pendant soixante images, retirez le mur, et la longueur revient progressivement au zoom total de dix mètres choisi par l'utilisateur au lieu de rester bloquée à la distance de collision.

Un seul contrat pour tous les colliders

Le système ne cherche jamais à savoir de quoi le monde est constitué. Un collider est simplement un objet doté d'une méthode probe. Le système lui transmet un rayon, une distance maximale et le rayon de la sonde de la caméra, puis reçoit une valeur : la distance maximale que la caméra peut parcourir avant d'être bloquée par ce collider. Le système interroge tous les colliders enregistrés et retient l'impact le plus proche. C'est tout le contrat, et il reprend exactement l'approche adoptée par le contrôleur de personnage lorsqu'il a transformé la locomotion en comportements interchangeables. Le terrain s'y branche, les objets s'y branchent, les enveloppes des bâtiments s'y branchent, chacun derrière la même méthode probe, tandis que le système reste inconscient de leur nature. Ajouter un nouveau type d'obstacle revient à ajouter un collider à une liste, pas à modifier la caméra.

Un détail du contrat justifie pleinement sa présence : le rayon de la sonde. Nous ne lançons pas un rayon fin du pivot vers la caméra ; nous projetons une sphère suffisamment grande pour contenir le plan proche de la caméra. Un rayon simple arrête le centre de la caméra au niveau du mur, mais le plan proche possède une largeur. Ses coins seraient donc déjà enfouis dans le mur avant même que le rayon central ne signale un impact. Balayer une petite sphère plutôt que lancer un rayon est la méthode utilisée par toutes les implémentations commercialisées. Unreal expose ce réglage sous le nom de taille de sonde, et c'est ce qui distingue une caméra reposant proprement contre une surface d'une caméra qui permet de voir au travers sur les bords de l'écran.

Nous ne choisissons pas ce rayon au hasard, nous le calculons. Le point du plan proche le plus éloigné de la caméra est l'un de ses coins, et sa distance se déduit directement de la projection. Avec un plan proche n et un champ de vision vertical θ, la demi-hauteur vaut h=ntan(θ/2), la demi-largeur vaut w=haspect, et le coin se trouve à

rnear=n2+w2+h2

Le rayon de la sonde correspond à cette distance jusqu'au coin, multipliée par une petite marge de sécurité, avec une valeur minimale fixe pour qu'il ne descende jamais sous un seuil raisonnable dans un frustum très étroit. Chaque fois que le champ de vision, le format d'image ou le plan proche change, le rayon est recalculé. Ainsi, le redimensionnement d'une fenêtre ou un zoom modifiant la projection ne peut pas rendre silencieusement la sonde trop petite pour couvrir les coins qu'elle est censée protéger.

Le collider qui connaît les grottes

Le collider du terrain est l'élément le plus intéressant, car le terrain de notre moteur n'est pas une carte de hauteurs. Depuis la partie 7, il s'agit d'un champ de distance signé, une fonction qui indique à quelle distance chaque point de l'espace se trouve de la surface solide la plus proche et s'il est à l'intérieur ou à l'extérieur de la roche. Une valeur positive désigne l'air, une valeur négative la roche. Ce simple fait permet à notre caméra de faire quelque chose qui est structurellement impossible avec une carte de hauteurs. Une carte de hauteurs connaît l'altitude du sol pour des coordonnées x et z données. Elle n'a aucune notion de plafond, car il n'existe jamais qu'une seule surface au-dessus de chaque point. Une caméra fondée sur une carte de hauteurs peut donc vous empêcher de pénétrer dans une colline, mais elle ignore totalement qu'un rebord en surplomb ou le plafond d'une grotte se trouve au-dessus du pivot, et elle traverse allègrement les deux. Un champ de distance connaît toutes les surfaces en trois dimensions. La même sonde qui arrête la caméra contre le flanc d'une colline l'arrête donc contre le plafond d'une grotte, sans le moindre cas particulier.

grotteroche solidejoueurcarte de hauteurs : traverse ✗SDF : s'arrête au plafond ✓
Une carte de hauteurs ne stocke qu'une seule surface par colonne. Elle ne voit donc jamais la dalle de roche située au-dessus du joueur et laisse la perche traverser le plafond. Le champ de distance est négatif à l'intérieur de cette dalle ; la sonde entre donc en contact avec elle et maintient la caméra juste sous la voûte de la grotte.

Faire progresser la sonde dans le champ est une technique qui possède un nom et un article de référence. Le Sphere Tracing de John Hart, publié en 1996, est la méthode standard pour faire avancer un rayon dans un champ de distance. L'astuce est que le champ ne se contente pas d'indiquer si vous avez touché quelque chose : il donne aussi la distance que vous pouvez parcourir en toute sécurité sans rencontrer d'obstacle. Au lieu d'avancer à minuscules pas fixes, vous échantillonnez donc le champ, progressez de la marge qu'il indique et recommencez, en faisant de grandes enjambées dans l'air libre puis des pas courts et prudents à l'approche d'une surface. Il y a toutefois un piège imposé par notre terrain. Un véritable champ de distance indique la distance euclidienne réelle jusqu’à la surface la plus proche, et avancer de toute cette marge est toujours sans risque. Mais là où le terrain est encore une carte de hauteur plutôt qu’un volume de voxels sculptés, le champ que nous pouvons échantillonner à peu de frais n’est pas une véritable distance : c’est un dégagement vertical, l’écart mesuré tout droit vers le sol. Sur une pente, cette valeur surestime la distance que la caméra peut réellement parcourir, car le rocher le plus proche se trouve sur le côté, et non directement en dessous. La distance réelle est plus petite d’un facteur qui augmente avec le gradient, 1+h2 ; un pas dimensionné d’après la marge indiquée dépasse donc la limite et peut sauter complètement par-dessus une crête. La solution consiste à appliquer une sous-relaxation, en n’avançant que d’une fraction de la marge indiquée plutôt que de sa totalité. Cela reste sûr jusqu’à une pente d’environ soixante degrés et ne coûte, dans les régions de voxels à distance réelle, que quelques échantillons supplémentaires. Nous y ajoutons une taille de pas minimale, afin qu’une paroi de grotte épaisse d’une ou deux cellules ne soit jamais entièrement franchie, ainsi qu’un plafond plus strict pour garder la marche peu coûteuse. Lorsque la sphère finit par toucher la surface, une courte bissection affine le point de contact, puis la caméra recule de son propre rayon et s’y immobilise.

Formellement, la perche est un rayon r(t)=p+td partant du pivot p dans la direction unitaire d, et la marche avance d’une fraction sous-relaxée de la marge avant contact indiquée par le champ, bornée aux deux extrémités :

tn+1=tn+clamp(λ(Φ(r(tn))r),smin,smax)

Ici, Φ est la distance signée, positive dans l’air et négative dans la roche, r est le rayon de la sonde, λ(0,1] est le facteur de sous-relaxation qui empêche le champ de la carte de hauteur, lequel surestime les distances, de franchir une pente d’un bond, et smin,smax sont les bornes du pas qui empêchent d’ignorer une paroi mince et évitent que la marche ne s’éternise. Le contact correspond au premier t pour lequel Φ(r(t))r, ce qui signifie que la surface de la sphère a atteint la roche ; la caméra se maintient alors à cette longueur d’arc diminuée de son rayon.

Le collisionneur fournit une méthode supplémentaire en guise de filet de sécurité : la dépénétration. La collision devrait d’abord empêcher la caméra d’entrer dans un solide, mais quelques situations peuvent lui échapper : un pivot à cheval sur une paroi assez mince pour que la perche commence à l’intérieur, une grotte tout juste creusée avec les outils de terrain alors que la caméra se trouvait dans la roche qui vient de disparaître, ou encore un élément de construction placé autour de la caméra. Dans ces cas, le collisionneur vérifie si la caméra termine l’image à l’intérieur d’un solide, là où Φ(c)<r. Si c’est le cas, il lit le gradient du champ, qui pointe directement vers l’air libre puisque Φ augmente lorsque l’on quitte la roche, puis déplace la caméra vers l’extérieur dans cette direction :

cc+(rΦ(c))Φ(c)Φ(c)

Une poignée de ces itérations converge vers l’isosurface de valeur $r`. C’est ce qui permet à un outil de sculpture de retirer le sol sous la caméra et à la vue de se rétablir dès l’image suivante au lieu de devenir noire.

Se rapprocher brusquement, s’éloigner en douceur et ne pas tressaillir devant les poteaux de clôture

Une perche qui saute simplement à la distance de collision à chaque image est pire que l’absence de perche, car le monde regorge d’objets minces derrière lesquels la caméra passe pendant une seule image : un poteau de clôture, un lampadaire, un tronc d’arbre. Une caméra qui se précipite pour éviter chacun d’eux puis repart tout aussi brusquement donne la nausée. Le livre de Haigh-Hutchinson et la conférence très appréciée d’Itay Keren sur les mouvements de caméra aboutissent tous deux à la même intuition : la caméra doit réagir aux menaces et au danger plus vite qu’elle ne s’en détend. L’amortissement est donc délibérément asymétrique. Lorsqu’un obstacle occultant apparaît et que la perche doit se raccourcir, elle se replie presque instantanément, car une seule image d’interpénétration est disgracieuse et les joueurs tolèrent un rapprochement rapide. Lorsque l’obstacle disparaît et que la perche veut s’allonger, elle se déploie lentement, et seulement après l’écoulement d’un bref délai pendant lequel le passage est resté continuellement dégagé. Ce délai constitue l’hystérésis qui élimine les tressaillements. Faites pivoter rapidement la caméra devant un poteau mince : le poteau ne reste jamais hors du chemin assez longtemps pour déclencher l’extension lente, si bien que la caméra le dépasse en douceur comme s’il n’était pas là, exactement comme l’œil le souhaite. Cinemachine expose la même idée sous la forme de valeurs distinctes d’amortissement à l’entrée et à la sortie d’une collision. C’est cette asymétrie qui donne l’impression d’un opérateur de caméra plutôt que d’un ressort.

Dans le code, cela tient en une ligne de lissage exponentiel, avec un taux choisi selon le signe de la variation. Si est la longueur actuelle de la perche, a la longueur autorisée par la collision pour cette image et Δt la durée de l’image, alors

+(a)(1ekΔt),k={kinakouta>,kinkout

et la branche d’éloignement progressif ne s’exécute qu’une fois le passage resté dégagé pendant le délai τ. La forme 1ekΔt n’est pas seulement élégante. Elle fixe la constante de temps de la réponse à 1/k indépendamment de la fréquence d’affichage, de sorte que la caméra se comporte de façon identique à 30 et à 144 images par seconde. Avec le mélange constant naïf +α(a), elle réagirait trop vite sur une machine rapide et de manière pâteuse sur une machine lente.

Le dernier comportement concerne les intérieurs exigus qui ont motivé toute cette partie. Lorsque la perche se replie suffisamment pour placer la caméra juste au-dessus du joueur, nous masquons son propre avatar et laissons la vue se rapprocher de la première personne. C’est ce que fait Breath of the Wild dans un sanctuaire exigu et la solution de repli de la plupart des jeux à la troisième personne dans un coin, car l’autre possibilité — une caméra coincée contre un mur et fixant l’arrière d’une tête — est inutilisable. Le rig expose un unique indicateur à cet effet, que la boucle du monde lit pour modifier la visibilité de l’avatar local. Avec une perche de moins d’un mètre, vous êtes de fait en vue à la première personne, les murs sont respectés et, dès que vous reculez dans une pièce suffisamment spacieuse, l’avatar réapparaît progressivement tandis que la perche se déploie.

Ce qui viendra se brancher ensuite

Le collisionneur de terrain est disponible dès aujourd’hui et représente la moitié la plus difficile, car le terrain est omniprésent et un champ de distance est délicat à sonder. Les objets et les enveloppes de bâtiments issus des prototypes de création constituent la moitié facile, et le contrat est déjà prêt à les accueillir. Un second collisionneur, fondé sur le lancer de rayons, lance un rayon du pivot vers la caméra contre une liste de maillages et signale l’impact le plus proche de la même façon que le collisionneur de terrain. La version peu coûteuse ne lance qu’un seul rayon, ce qui suffit jusqu’à ce que le nombre d’objets augmente. L’amélioration consiste à remplacer le rayon par une sphère balayée à l’aide de three-mesh-bvh, la bibliothèque de Garrett Johnson qui enveloppe un maillage dans une hiérarchie de volumes englobants afin que les requêtes spatiales s’exécutent en temps logarithmique plutôt que par force brute. Dans les deux cas, le rig ne change pas. Il interroge une liste plus longue de collisionneurs et retient l’impact le plus proche, ce qui est précisément la raison pour laquelle nous avons conçu d’abord le contrat, puis les collisionneurs.

Technologies abordées dans ce chapitre

Une perche qui ne gère qu’une seule valeur. Le rig de caméra est un post-traitement appliqué à OrbitControls, et non son remplaçant. Il gère la longueur de la perche et laisse le lacet, le tangage, le zoom et les gestes au contrôleur orbital auquel nous faisions déjà confiance. L’intégration repose sur deux appels d’encadrement : beforeControls() rétablit la distance complète de l’image précédente afin que les calculs orbitaux lisent le véritable zoom choisi par l’utilisateur au lieu de confondre un raccourcissement dû à une collision avec un rapprochement de caméra, puis afterControls(dt) résout la collision et écrit la position affichée. Sans cette paire, la caméra se rabat sur le joueur en quelques images.

Un contrat de collisionneur interchangeable. Un collisionneur est n’importe quel objet doté d’une méthode probe qui répond à la question : « quelle distance la caméra peut-elle parcourir depuis le pivot le long de ce rayon avant que vous ne la bloquiez ? » Le rig interroge chaque collisionneur et retient l’impact le plus proche, sans savoir si l’obstacle est le terrain, un objet ou un mur. C’est la même discipline consistant à ne gérer qu’une seule responsabilité que celle employée par le contrôleur de personnage interchangeable pour la locomotion. La sonde est une sphère balayée dimensionnée pour contenir le plan proche, et non un rayon fin, afin que les coins du tronc de vision n’interpénètrent jamais une surface que le rayon central aurait manquée. Son rayon est dérivé de la projection : la distance jusqu’à un coin du plan proche, n2+w2+h2, multipliée par une marge de sécurité. Il est recalculé chaque fois que le champ de vision, le rapport d’aspect ou le plan proche change.

Une sonde de terrain à distance signée qui respecte les surplombs et les grottes. Puisque le terrain est un champ de distance signée plutôt qu’une carte de hauteur, la même sonde qui arrête la caméra contre un versant l’arrête contre le plafond d’une grotte, cas qu’un lancer de rayon sur une carte de hauteur est structurellement incapable de détecter. La marche emploie le traçage de sphères de Hart : elle avance d’une fraction sous-relaxée de la marge indiquée par le champ, avec une borne à chaque extrémité, car les régions fondées sur une carte de hauteur indiquent un dégagement vertical plutôt qu’une véritable distance et surestiment le déplacement possible de la caméra sur une pente ; un pas complet franchirait donc une crête. Une passe de dépénétration guidée par le gradient sert de filet de sécurité et rétablit la vue lorsque le sol est sculpté sous la caméra.

Un amortissement asymétrique avec délai d’attente. La perche se replie rapidement lorsqu’un obstacle occultant apparaît, puis se redéploie lentement lorsqu’il disparaît, et seulement après une courte période de dégagement continu. Ainsi, un mouvement rapide devant un poteau de clôture ne fait jamais bondir la caméra. Sous un seuil de repli, le rig active un indicateur de vue quasi subjective et la boucle du monde masque l’avatar local, solution de repli habituelle dans les intérieurs exigus plutôt que de laisser la caméra enfouie dans un mur.

Références

La formulation du contrôle de caméra comme une contrainte de visibilité vient de Marc Christie et Patrick Olivier, Camera Control in Computer Graphics (Computer Graphics Forum, 2008). La marche dans le champ de distance est celle de John C. Hart, Sphere Tracing: A Geometric Method for the Antialiased Ray Tracing of Implicit Surfaces (The Visual Computer, 1996). Le modèle de perche à ressort et sa collision par sphère-sonde sont documentés dans le Spring Arm Component d’Epic ainsi que dans le Cinemachine Deoccluder et le Third Person Follow d’Unity. L’intuition concernant le mouvement et l’amortissement vient de Mark Haigh-Hutchinson, Real-Time Cameras (Morgan Kaufmann, 2009), et d’Itay Keren, Scroll Back: The Theory and Practice of Cameras in Side-Scrollers (GDC 2015). La voie d’amélioration du collisionneur de maillages repose sur three-mesh-bvh de Garrett Johnson.


Partie 30 sur 30. Précédent : Partie 29 — Un contrôleur pour n’importe quel corps Guide de la série : /fr/blog/2026-02-25-open-world-browser-series-guide