Skip to content

Construire un monde ouvert dans le navigateur, partie 21 : un moteur de rendu plus rapide qui ne l'était pas

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 renvoie vers toutes les parties.

La partie 20 simulait la profondeur d'une surface sur un quad plat. Cette partie porte sur une décision d'architecture de rendu, et c'est le spike où la réponse des manuels s'est révélée inadaptée à notre matériel. La question était la suivante : pour de l'herbe à découpe alpha d'une densité cinématographique, sous une caméra à la troisième personne, avons-nous besoin d'un buffer de visibilité avant de passer à une densité adaptée à 200 joueurs ? Le conseil généralement donné est un oui catégorique. Nous l'avons construit, mesuré, et la réponse était non.

La technique que tout le monde recommande

Ouvrir le spike 40 dans un nouvel onglet ↗ · Voir le code source

Un buffer de visibilité divise le rendu en deux passes. La passe 1 rastérise la géométrie et écrit uniquement les identifiants des triangles et des instances dans une cible entière compacte, avec la profondeur, sans effectuer le moindre shading. La passe 2 est une passe plein écran qui lit les identifiants de chaque pixel couvert, récupère à nouveau les sommets du triangle concerné, reconstruit les attributs interpolés et calcule le shading de chaque pixel visible exactement une fois. L'argument de vente est un rejet parfait de l'overdraw : le test de profondeur s'applique à des fragments qui n'ont subi aucun calcul de shading, de sorte que le matériau coûteux n'est exécuté que sur ce qui est réellement visible.

Le spike exécute deux chemins de rendu sur un même canevas et un même appareil, afin que la seule variable soit l'endroit où le shading est effectué. Le chemin forward utilise un MeshStandardNodeMaterial normal via three.js. Le chemin du buffer de visibilité est un pipeline WebGPU brut à deux passes, exécuté en dehors de three.js. Il lit la texture d'herbe de three.js directement depuis le backend, écrit (instanceId, triId) dans une cible RG32Uint pendant la passe 1, puis résout l'éclairage pendant la passe 2. Les deux chemins partagent la même configuration d'éclairage de référence.

Deux remarques d'implémentation méritent d'être retenues. WebGPU ne possède toujours pas de builtin primitive_index portable dans les fragment shaders. L'astuce consiste donc à intégrer un identifiant de triangle par sommet dans une géométrie non indexée, puis à le lire avec une interpolation flat. Cela multiplie par trois le nombre de sommets, mais le coût est négligeable pour une carte d'herbe de 12 sommets. Par ailleurs, partager le canevas avec le moteur de rendu de three.js ne pose pratiquement aucun problème, tant que vous ne reconfigurez jamais le contexte et ne modifiez pas les dimensions du canevas, qui appartiennent tous deux à three.js. Le plus délicat a été de mesurer le chemin forward, car three.js ne fournit aucun hook permettant d'insérer des requêtes de timestamp GPU dans sa passe de rendu. La solution de contournement consiste à encadrer son travail par deux passes de timestamp sans opération, soumises avant et après, que le GPU exécute dans l'ordre de soumission.

Les chiffres évoluent dans le mauvais sens

Sur un Mac de série M à environ 1080p, avec des brins d'herbe composés de cartes croisées répartis sur un terrain de 80 m :

Avec 50 000 instances, le chemin du buffer de visibilité l'emportait de 25 %, avec 4,13 ms contre 5,51 ms pour le rendu forward. À 100 000 instances, les deux étaient à égalité. À 200 000 instances, le rendu forward l'emportait de 44 %, avec 5,44 ms contre 7,80 ms pour le buffer de visibilité. Le chemin du buffer de visibilité devient relativement moins performant à mesure que la densité augmente, soit l'exact opposé de l'idée répandue selon laquelle il gagne précisément lorsque l'overdraw est important.

Pourquoi le rendu forward tient bon

Les GPU Apple Silicon sont des moteurs de rendu différé par tuiles, ce qui change complètement le calcul. Le rendu forward sur un TBDR dispose d'une étape cachée d'élimination des surfaces masquées qui s'exécute avant le fragment shader : le rastériseur collecte tous les fragments associés à une tuile, les trie par profondeur, et seuls ceux qui subsistent après le test alpha atteignent le fragment shader. Le chemin forward bénéficie donc déjà gratuitement, au niveau matériel, de l'essentiel de la promesse du buffer de visibilité consistant à « calculer le shading une seule fois par pixel ». À mesure que les brins envahissent l'écran, davantage de fragments sont rejetés par l'élimination des surfaces masquées avant le moindre calcul de shading, et le coût effectif par pixel du rendu forward reste à peu près constant au lieu d'augmenter avec l'overdraw.

La passe 1 du chemin du buffer de visibilité bénéficie du même avantage du TBDR. Le problème se situe entièrement dans la passe 2. Celle-ci lit, pour chaque pixel, la matrice de son instance depuis un buffer qui atteint 12,8 Mo avec 200 000 instances, soit bien plus que la capacité de n'importe quel cache GPU. Les pixels voisins à l'écran appartiennent généralement à des instances d'herbe différentes — la répartition repose sur une grille avec jitter, si bien que les brins voisins possèdent des identifiants d'instance arbitraires — et chaque wave qui accède à ce buffer subit donc des défauts de cache divergents. À lui seul, cet accès aléatoire incohérent coûte environ 4 ms par image. Le rendu forward l'évite entièrement, car la matrice d'instance arrive avec le sommet via les attributs par instance. Lorsque le fragment shader s'exécute, les données transformées des sommets se trouvent donc déjà dans les registres locaux de la tuile, sans nécessiter de lecture aléatoire à l'échelle de plusieurs mégaoctets.

C'est exactement le coût que la passe de classification des matériaux de Nanite cherche à amortir : elle regroupe les pixels par instance et lance des waves de calcul triées afin que les lectures de chaque wave soient cohérentes. Nous ne disposons pas de cela. Une estimation rapide indique qu'un tri des pixels par instance ramènerait ces 4 ms à environ 1,5 à 2 ms et repousserait le point de croisement à 400 000 ou 500 000 instances. Mais cela reviendrait à empiler des optimisations sur une architecture qui n'est déjà pas gagnante ici.

La conclusion honnête, et l'audit qui l'a rendue possible

Pour du feuillage à cartes croisées avec test alpha sur Apple Silicon via WebGPU, le chemin forward utilisant le pipeline TSL de three.js présente déjà un coût inférieur ou égal à celui du buffer de visibilité. Toute la plomberie du buffer de visibilité n'apporte aucun gain perceptible avant de dépasser largement 200 000 instances, et seulement si l'on ajoute également une passe de tri ou de regroupement. Pour le moteur de production, la décision pratique est donc de conserver l'ensemble rendu forward, LOD et imposteurs issu des spikes précédents, et de ne pas investir dans une infrastructure de buffer de visibilité tant que nous ne ciblons pas principalement des GPU NVIDIA ou AMD dédiés — sur lesquels le coût de l'overdraw est plus linéaire — ou tant que nous ne passons pas à une architecture à meshlets, où le buffer de visibilité constitue de toute façon la sortie naturelle.

Comme ce résultat est contre-intuitif, la conclusion n'a de valeur que si la comparaison est équitable. Le spike a donc fait l'objet d'un audit complet. Plusieurs vrais bugs ont été découverts et corrigés : un curseur d'échelle des brins qui désynchronisait silencieusement les deux chemins, la moitié des brins du rendu forward qui apparaissaient presque noirs à cause de normales antiparallèles — corrigées grâce à la technique classique des normales de feuillage orientées vers le haut — et le buffer de visibilité qui produisait une image environ deux fois trop lumineuse à cause d'un facteur de Lambert choisi manuellement au lieu du facteur 1/π respectant la conservation de l'énergie, d'un terme ambiant codé en dur et de l'absence de tone mapping. La correction a consisté à reproduire exactement dans WGSL la courbe filmique ACES de three.js, puis à lire à chaque image les couleurs et les intensités des lumières directement depuis les éclairages réels de la scène. La seule différence connue restante, l'absence de spéculaire directe dans la passe 2, favorise le buffer de visibilité. Le rendu forward effectue donc strictement plus de travail par pixel et continue malgré tout de gagner à haute densité. La conclusion principale est ainsi prudente, et non optimiste. Une réserve subsiste : tous ces résultats sont propres aux puces de série M, et le point de croisement pourrait très bien s'inverser sur une carte dédiée. Il faudra donc relancer les mesures avant d'adopter cette pile pour des cibles non-Apple.

Technologies abordées dans ce chapitre

Rendu avec buffer de visibilité. La passe 1 rastérise la géométrie et écrit uniquement les identifiants des triangles et des instances, ainsi que la profondeur, sans effectuer de shading. La passe 2 est une résolution plein écran qui lit les identifiants de chaque pixel couvert, récupère à nouveau le triangle source, reconstruit les attributs barycentriques corrigés en perspective et calcule une seule fois le shading de chaque pixel visible. Comme WebGPU ne possède pas de primitive_index portable pour les fragments, l'identifiant du triangle est intégré sous forme d'attribut par sommet interpolé avec flat dans une géométrie non indexée.

Élimination des surfaces masquées d'un TBDR contre résolution différée. Sur un GPU à rendu différé par tuiles comme Apple Silicon, le rendu forward rejette déjà les fragments occultés avant l'exécution du fragment shader. Il bénéficie donc gratuitement de l'essentiel de l'avantage du buffer de visibilité consistant à n'effectuer le shading qu'une fois, et son coût par pixel reste à peu près constant lorsque l'overdraw augmente. À l'inverse, une passe de résolution d'un buffer de visibilité doit assumer le coût d'accès aléatoires incohérents à un vaste buffer par instance — 12,8 Mo pour 200 000 instances — qui devient dominant à haute densité, sauf si les pixels sont d'abord triés ou regroupés par instance, comme le fait la classification des matériaux de Nanite.

Partage d'un canevas avec le WebGPURenderer de three.js. Les command buffers WebGPU bruts s'intercalent correctement avec les soumissions de three.js dans la file partagée, tant que vous ne rappelez jamais context.configure() et ne modifiez pas canvas.width/height, deux éléments contrôlés par le moteur de rendu. Le temps GPU du chemin forward, pour lequel three.js ne fournit aucun hook, peut être mesuré en encadrant son appel de rendu par deux passes de rendu de timestamp sans opération. Le GPU exécute en effet les command buffers dans l'ordre de soumission.

Validation d'un benchmark contre-intuitif. Un résultat de performances surprenant n'est fiable que si la comparaison est équitable. L'audit des deux chemins pour garantir un contenu de scène et un shading identiques — même tone mapping ACES, modèle de Lambert respectant la conservation de l'énergie, lumières lues depuis les mêmes objets et échelle des brins identique — a permis de transformer « le buffer de visibilité est plus lent », d'un probable artefact de mesure, en une conclusion défendable. La seule asymétrie restante favorise l'interprétation la plus prudente.


Partie 21 sur 29. Précédent : Partie 20 - Simuler la profondeur sur un plan plat Suivant : Partie 22 - Des nuages que l'on peut traverser, et un culling qui porte ses fruits Guide de la série : /fr/blog/2026-02-25-open-world-browser-series-guide