Radiation de pixels, quand la 2d est 3D et vice versa

Suite des articles visant à améliorer l’illumination de la scène, après une couleur diffuse correctement éclairée grâce aux normales et aux spéculaires, il restait, invariablement, que l’image entière était éclairée uniformément concernant l’intensité, pour une même direction de normales.

Mise à plat de la scène pour valider le dégradé de l’intensité.

Les calculs et les informations transmises étaient insuffisantes pour calculer la valeur de chaque pixel et, surtout, rien ne nous permet de savoir où le pixel se situe dans l’espace, nous permettant de faire varier son illumination sur base d’une distance altérée par sa position.

Je ne suis pas 3D

N’oubliez pas que ce que vous voyez n’est pas de la 3D, mais une succession d’images plates représentant « un quelque chose » en 3D, et c’est cet enchevêtrement savamment orchestré qui vous dit que l’image est 3D. Tout n’est qu’illusion Mr. Anderson ! Et votre cerveau vous trompe, et c’est le but !

Je l’ai déjà dit dans les précédents articles, mais c’est là tout le problème, comment donner une information 3D à une image plate ? Certes nous avons les normales pour nous donner la direction, mais qu’en est-il de la position ? Comment puis-je savoir que le haut de mon bloc est une surface au Z équivalant, variant sur X et Y ?

Explication du process depuis le début.

Reprenons depuis le Zébut

Un Tile est l’image représentant une zone, une unité de notre grille. Celle-ci a ses coordonnées X et Y. La distance entre leur position sera de 1 (1x, 1y ou 1x1y).

L’éclairage d’un Tile est soumis à la propagation de la lumière dans le subset de la map courante (cela nous rappelle de vieux souvenir [2018 !]). La propagation reste importante pour savoir quel Tile est impliqué dans l’éclairage (optimisation).

Un subset reprend une partie des éléments du monde concernés par le rendu à l’écran.

On parlera donc de la distance (parcourue ici) entre le Tile que l’on veut éclairer par rapport à la source lumineuse et sa force.

Une lumière peut donc éclairer sur une distance (d) un objet proportionnellement à sa force (S) à cette distance et selon l’intensité (i) de cette lumière. Nous obtenons alors le facteur (f) d’éclairage du pixel (rgb aux coordonnées uv).

f = (1 - (d * (1 / S))) * i

On obtient une valeur (float) allant de 0 à 1, qui sera notre facteur d’éclairage du Tile.

Ça c’était avant

Ce qu’il nous manquait jusque là c’est d’avoir toutes les coordonnées au niveau du shader pour effectuer ce calcul de distance au niveau du pixel.

On calculera ainsi notre distance autrement, c’est-a-dire sur base des coordonnées du Tile (t) et de la lumière (l), sans oublie l’intensité (i).

d = √((l.x - t.x)² + (l.y - t.y)²) * i

Donc on propage, on définit pour chaque Tile concernés les lumières impliquées, on ajoute les informations manquantes jusqu’alors et … ? Ben jusque là rien de nouveau sauf une distance plus correspondante à l’origine mais pas par rapport aux obstacles rencontrés entre la source et l’élément éclairé, car ça, je suis sur que vous les aviez oubliés.

On ne traverse pas un mur, la lumière « contourne » par rebond etc. Donc la propagation reste la bonne manière mais ne donnera pas le bon résultat en isométrique, et j’espère que vous comprenez pourquoi avec toute cette suite d’articles ahah. En bref, on a pas la possibilité de savoir comment la lumière est arrivée à chaque pixel, il faudrait calculer pour chaque objet un pixel de rebond (surement sa normale), balayer ainsi dans tous les sens et à chaque contact : éclairer ce pixel et modifier le rayon pour le faire repartir (couleur, angle, puissance, …), bref on réinventerai un rendu par projection de rayons en 2D sur base d’information 3D stockée dans les textures. Non !

Spatiale 3D dans de la 2D

Ce qu’il nous manque à cette étape est le moyen de faire évoluer notre formule de calcul de la distance pour inclure la position du pixel rendu de notre texture.

Nous avions vu plus haut que le Tile se définit par une position X et Y, ici en sont centre, sur la partie supérieur pour représenter le sol. Nous ne parlerons pas de Z ici mais l’idée sera la même une fois que j’aurais réussi à ramener l’information jusqu’au shader.

Si un Tile est à une distance de 1 de ses voisins alors il a à ses extrémités un delta variable de [-0.5, 0.5] en X et en Y. Z étant, lui, soumis à un facteur d’élévation définit par la scène, ici de 8 pixels, et donc aura une idée différente de sa distance, du moins c’est ainsi que l’élévation avait été définie au tout début.

Nous avions jusque là 3 textures : la diffuse (l’image elle-même), la normale (direction d’éclairage) et le spéculaire (carte de où appliquer le spéculaire). Et bien nous allons en ajouter une nouvelle.

Suivant la même idée que pour la texture de normales, nous réutiliserons notre définition RGB de nos repères : X en rouge, Y en vert et Z en bleu. Pour chaque pixel de l’image nous définirons ainsi sa position relative par une valeur RGB. Ne pas oublier que le centre ne sera pas 0, car nous avons besoin des deltas négatif, ainsi nous commencerons au centre à 128,128,128.

Un programme, un plugin, ou une théorie pour le faire de manière automatique n’existant pas, on va devoir y mettre de l’huile de coude.

Ainsi comme vous pouvez le voir, si on veut s’aider, nous pourrons compter sur les gradient, un bon dégradé bien réfléchi qui sera combiné à une couche supplémentaire. Nous commencerons par le XY et on surcouchera par le Z. Un total de 2h nécessaire avant d’arriver au résultat que voici.

Notre formule de distance peut donc être complétée par cette nouvelle information (pp).

d = √((l.x - (t.x + pp.x))² + (l.y - (t.y + pp.y))²) * i

Ce qui nous donnera sans plus attendre ce premier résultat :

Wow que se passe-t-il ? On a notre dégradé * fête * cependant nous avons un drôle d’aspect sur certain Tile. Notre algo utilise la force (S) pour se propager, mais comme vous l’avez vu, la propagation est plus courte que le segment calculable en ligne droite donc nous arrivons trop court, nous n’avons pas assez s’électionné de Tiles pour notre impact. L’idée serait surement de refaire un calcul sur base de la distance au lieu de calculer le nombre de saut, mais pour le workaround j’ai ajouté +2 à la force (S).

On en profite pour restaurer le calcul du spéculaire maintenant que nous avons de vraies informations et ce foutu éclair blanc au coffre s’en va !

Il y a bien évidemment des effets de bords, comme le fait que la distance parcourue n’étant plus impliquée, si vous fermez la porte, le Tile de l’autre côté sera éclairé comme si vous étiez à côté au lieu d’un faible éclairage (la lumière fait le tour du mur). Les tables clignotent et là je ne sais pas encore pourquoi. C’est plus joli mais moins cohérent. On en revient à l’idée des rebonds, du vecteur qui doit bouger selon l’idée mais rendre chaque Tile cohérent entre eux, là je ne vois pas comment.

J’ai quand même regardé comment ajouter la distance à la formule mais le résultat empirique n’est pas terrible.

Ajouter la distance assombrit la scène

Et en fait si on déplace la lumière selon la distance parcourue dans la même direction, on éloigne alors virtuellement notre Tile voisin. Et ainsi le résultat tiens compte de la distance. Ceci dit l’éclairage en a pris un coup, il y a surement quelque chose à faire avec ça.

lightPos += lightDirection * lights[i].distance;

Pour bien faire il faudra savoir si la distance correspond à un voisin, et ainsi différencier si notre voisin a une distance de 4 ou de 1 et appliquer l’éloignement que dans ce cas. Reste à trouver comment faire.

Et ensuite ?

Comme toujours, quelle est l’étape suivante et comment adresser les nouveaux problèmes, ou ceux mis de côté ? Plus j’avance dans TARS et plus les limites, personnelles et techniques se montrent. Il est grisant de se dire que j’ai relevé un défi qui, selon Google, n’a pas été adressé ou n’est actuellement plus référencé. Il faut aussi se rendre compte de l’effort considérable que cela demanderait si l’on voulait faire un jeu entier ainsi. Et là encore nous n’avons exploré que la lumière (par rapport à l’existant réalisé).

C’est le cœur serré que je me dis que j’ai clôturé cette étape, et qu’il faut, car le temps n’est pas infini, et que je n’ai pas que ce projet à faire, passer à autre chose… Quand je dis « passer à autre chose« , ce n’est certainement pas abandonner Nahyan, certes non ! Mais explorer (enfin) une autre piste que celle de tout faire moi-même, même si je n’ai pas fort le choix. Je ne vais pas en dire plus pour le moment, j’ai déjà commencé à explorer d’autres possibilités et vous raconterais le moment venu.

Cela fait 7 ans que TARS existe, avec une belle pause au milieu, et 2-3 belles phases de développement, comme le passage au WebGL pour adresser les performances sur l’éclairage première version, ou encore la refonte des classes communes pour mieux gérer les données et factoriser le code générique. Et enfin ici, le nouveau système d’éclairage, mais in fine rien de plus, nous n’avons toujours pas vu « Le temple du Prisme« , un HéroQuest ou un Liebesstein.

Qui sait que j’y toucherai par défi encore 😉

Fusion de la chambre intermix

Suite à l’article précédent j’ai voulu faire un tour de nettoyage et je me suis rendu compte qu’un ancien démon revenait à la charge. Nous avons divisé le code par domaine et contexte de rendu, propre et héritant d’un parent commun, et dans un service je mets le code commun à ce qui concerne Grid, ou Iso etc., de manière à isoler les calculs de l’usage selon le contexte.

Schéma avant fusion

Plus simple avec un schéma, voici la découpe avant fusion. Ce qui nous intéresse c’est la séparation 2D et Gl, puis la découpe par usage/type à savoir Sprite, Grid ou Iso, puis les regroupements de type IsoElement/GridElement, ainsi que des services relatifs aux couches.

On a une sorte de matrice à 3 dimensions en ce qui concerne cette idée. Sauf que programmer ça, en TypeScript, ben c’est pas très évident. En PHP j’aurai pu utiliser des Traits, il existe des mixins en javascript mais non merci, je vous laisse vous faire votre avis mais ce n’est pas à la hauteur. L’héritage multiple n’existe pas (cf mixin), du coup il faut savoir se renouveler et faire preuve d’audace, d’expérimentation et de refontes inévitables. C’est ce que j’ai dû faire, non sans mal.

J’étais parti pour déplacer les fonctions de rendu dans les services par couche (Grid/Iso) et de fusionner Grid2DElement avec GridGlElement, vu que leur différence réside dans le contexte de rendu (2D/Gl). Mais je me suis aperçu que bien que je gagnais en clarté à tout regrouper, on augmentait d’autre part la difficulté de ce même code et des approches. Bref, bien, mais pas bien.

Du coup revirement de situation et revenons sur nos pas de plusieurs mois quand on a justement décidé de diviser par contexte de rendu, quand les lumières sont arrivées (la version solutionnée). Revenons donc à cette idée non divisée et sans emmerder les services déjà très bons tels quels.

C’est la fusion !

Fusionner Grid2D et GridGl, Iso2D et IsoGl, ok, mais on oublie Sprite2D et SpriteGl qui héritent de SpriteElement, il faut commencer par le commencement. C’est donc une refonte jusqu’à Element pour répartir les morceaux des différentes classes Sprite*. Ainsi disparaissent Sprite2DElement et SpriteGlElement au profit d’une nouvelle classe SpriteElement toute équipée.

Quand on parle de fusion on parle bien entendu de gérer le contexte de rendu au sein même de la classe. Ceci peut paraître étrange et contre certains bons principes, mais ces morceaux de code partagent parfois jusqu’à 90% du code, ce qui va contre le principe DRY (Don’t Repeat Yourself) et comme j’ai envie de bisous (KISS : Keep It Stupid Simple), j’ai tout regroupé et cela ne m’a demandé que quelque if peu coûteux, ce qui est très important car on appelle ces bouts de code des centaines, des milliers de fois par seconde (selon complexité de votre projet).

Fusion de Sprite*Element

J’en profite pour illustrer le service de rendu Gl et montrer à partir d’où on le connecte. J’en affiche un peu plus mais ainsi on voit les 2 types de regroupement GridElement et IsoElement. Ne prêtez pas attention à GridBlock, vous le connaissez déjà.

Nous sommes bien parti, continuons. On crée une nouvelle classe GridElement et on met ce que contient Grid2D et GridGl, hop tout dedans et on essaye de faire coller les morceaux. Là où ça se corse c’est de bien segmenter les parties communes et spécifiques, puis quand on arrive à IsoElement c’est encore pire car nous sommes basé sur l’héritage donc on ne réécrit que ce qui a besoin de l’être et là on observe des couacs, des oublis pour la 2D vu qu’on s’est concentré sur Gl depuis les lumières. Par exemple la table en 2D ne fonctionne pas, juste en Gl.

Fusion terminée

Sur papier ça parait plus beau, naturel, élégant, [mettre ici tous les beaux mots que vous désirez]… Mais dans la pratique ça demande pas mal d’efforts, de compréhension, évidemment c’est pour un mieux !

C’est quand même un impact de 33 fichiers dans le projet et son POC de démo, 8 dans TARS même, ainsi que 8 suppressions et 2 ajouts. Ça c’est pour les fichiers, mais en terme de lignes de code, même si je n’ai pas de compteur à cet instant, on a effectué une réduction notable, donc plus facile à maintenir, de par le regroupement aussi.

Preuve que ça fonctionne toujours, même si la table n’est pas gérée encore correctement en 2D.

Garder 2D et Gl ?

Pourquoi garder les 2 ? Car il est très difficile de débuger en Gl, vous ne pouvez pas dessiner aisément un repère ou une trace sans sortir les chars d’assaut et beaucoup d’heures de dev alors qu’en 2D vous êtes libre de manipuler le rendu en direct et ce rapidement.

C’est pour cela que le POC (démo) n’utilise plus les lumières en 2D, le but pour moi ici étant un debug rapide sans ajouter des soucis de performances, qui plus est, connus.

De plus, maintenant que la fusion est faite, on pourrait revoir tout le workflow d’utilisation pour ne plus devoir faire (à l’usage) une scène 2D ou une scène Gl. ainsi en changeant juste le mode, toutes les classes personnelles seraient utilisables, ce qui serait un chouette gain de temps et d’effort. De plus les 2 fonctions ajoutées is2DContext/isGlContext permettent d’agir spécifiquement si besoin était.

Et le fameux ensuite ?

Ah ben oui, ensuite quoi ? Corriger le pourquoi de la table en 2D et tenter de régler un conflit Grid/Iso sur un calcul de positionnement.

Il faut absolument faire un POC purement Grid et non Iso, genre un Mario (S)NES ou Duke Nukem 1-2, un truc tout carré pour voir que les calculs sont bons, juste une grille décorée, pas plus.

Ensuite, mes fameuses particules lumineuses et le miroir :p !

Mais bon, comme d’hab on verra ce qui me stitch comme on dit. Je vais déjà de ce pas fusionner les branches du repo et repartir sur une base saine :).

Edit

Pour ne pas refaire un article juste pour ça, j’ai également fusionné les classes du POC (treasure, door, player), leur code était identique. 3 classes de moins sur les 6 initiales (3x2D et 3xGl). Une bonne chose de faites qui va nous simplifier la vie ultérieurement. Les scènes restent dissociées car différentes, la Gl, plus complète, gère la lumière par exemple, les fusionner ne donnerait rien d’intéressant une fois en release. Au moins le debug (2D) peut se faire sans gêner le résultat final (Gl). Nous voilà dans un état propre, quelques corrections de bugs ou de manques seraient à faire pour solidifier avant d’avancer plus.

Illumineux

Ça y est, j’ai pris à bras le corps le sujet des lumières qui étaient encore à l’essai, ramasser les TODOs (pas tous) et figer dans le système et son POC, ces mécaniques.

Ainsi Squellettore notre héro et le coffre sont détectés dans la subsetMap comme éléments lumineux. Ils contiennent une ébauche d’informations de lumière à savoir une couleur, une force et un état allumé/éteint.

Là où nous appliquons une modification de luminosité (brightness) basé sur la distance de la source de lumière, dorénavant nous appliquons la lumière, c’est à dire sa couleur selon sa distance. Ainsi, notre magnifique shader qui permet cette opération est enfin utilisé par le système et non plus via mes essais manuels.

Évidemment cela ne s’est pas fait en 2 tours de cuillères à pot… Il a fallu définir une information de lumière, faire les fonctions d’usage, les modificateurs de variables, faire la différence entre être une lumière et illuminer, ce qui est fort important je vous l’assure pour le bon nommage de vos éléments; et comme dit ci-avant, la détection de ces lumières dans la map automatiquement. Ceci a engendré des correctifs bien utiles et des améliorations en retirant la redondance et simplifiant certains déroulements.

Ensuite nous avions des murs mal illuminés, c’est à dire que les murs, ou éléments de la zone, Sud et Est appartiennent à un gridBloc (carré sur le sol) mais en fait concernent leur case voisine, car vous les observez depuis une autre case, donc leur illumination ne doit pas se faire depuis la case les possédant mais bien par leur case voisine respective.

En bas à droite de l’image ci-dessus vous verrez un mur quasi noir derrière un mur éclairé. C’est du fait que l’élément est masqué virtuellement par un élément non traversable. En conséquence il ne reçoit aucune lumière et ne subit en fait que l’éclairage global. Cela relève du choix personnel lors de votre implémentation en utilisant le moteur, j’ai fait ce choix pour ma démo.

Un autre point que cet article va traiter est la rotation de Squellettore, notre intrépide héro, qui, tel un Derek Zoolander, peut enfin illustrer et parfaire son mouvement de rotation gauche ET droite !

Son déplacement, suite au résultat du pathfinder, est une suite de points à suivre tel Hansel et Gretel. Quand un changement de direction survient actuellement on applique la réorientation du personnage sans autre cérémonie. C’est là que ça commence, il faut s’insérer temporairement dans le mouvement du déplacement le temps d’animer une rotation puis de relancer le déplacement, le tout sans se planter.

Je vous passe la construction d’une technique de détection de direction de rotation, et les emmerdes dues aux croisements d’informations sur les animations en cours et les modifications de l’animation courante qui se chevauchaient, annulant ainsi ce qu’on essaye de faire.

Du coup, en s’abonnant à l’événement de fin d’animation et en détectant que l’on change de direction, l’on peut intercéder, couper le déplacement, lancer l’animation de rotation dans le bon sens, attendre la fin de celle-ci et une fois fait relancer la prochaine étape du déplacement. Aussi simple, même s’il m’aura fallu 6h pour affronter tous les soucis desquels je vous épargne un tantinet.

Il reste cependant à mieux gérer la rotation pour l’utiliser également quand on cible un objet à côté de nous pour interagir avec. Si on tourne en se déplaçant, pourquoi regarder d’un coup un objet à côté de nous.

Dans la gamme du « reste à faire », j’aimerai tenter d’adoucir le changement d’éclairage au déplacement, plus gourmand, complexe, mais pas forcément une mauvaise idée. À suivre donc.

On a donc ici, une amélioration, et non des moindres, de l’atmosphère de notre rendu, de la gestion générale des lumières et un essai d’animation intermédiaire qui convainc plus encore la démarche de notre héro.

Il y a toujours de quoi faire en éclairage, ombres et ombrages et effets visuels variés, on peut citer le normal mapping, des particules, des shaders d’altérations, et bien plus encore, mais tout cela relève surtout de ce que vous voulez faire dans votre projet. Le POC ici arrivent tout doucement à sa fin et un projet devrait débuter sous peu dès que j’aurais revu l’architecture globale et tenter d’éliminer les TODOs restant.

TARS n’est pas parfait, loin de là, mais il faut le mettre à l’épreuve et, au travers d’un projet, corriger et améliorer ce qui sera nécessaire.

Case départ, et plus si affinités

Nous y sommes, nous sommes revenu à la case départ, au même niveau qu’au moment du constat sur les performances avec les lumières. Ça compile, ça fonctionne, soupir de soulagement, joie intérieur et stress futur quand on voit ce qu’il a fallu pour en arriver là, et donc qu’il faudra arranger. Mais profitons de l’instant présent, du succès immédiat, de ce haut-fait personnel, de cette joie temporaire mais précieuse.

Profitons en pour rire un peu avec quelques captures et commentaires utiles.

Au commencement : ça fonctionne, d’apparence, bon positionnement, chaque chose à sa place, le curseur bouge, bref idéal. À savoir que les objets complexe comme le coffre ou la porte sont arrivés plus tard car il a fallu traduire vers la 3D objet par objet, et au final se rendre compte que les abstractions ne nous oblige qu’à changer la classe de laquelle on hérite, ce qui est parfait mais pose un problème de logique de conception, en sachant que le TypeScript ne permet pas les Traits, mais veut faire du Mixin auquel je m’y refuse (pas beau, pas maintenable, …).

Comme nous n’avons pas de Traits, j’ai donc été obligé de garder l’héritage, ainsi Element (en tête), devient un SpriteElement, qui à son tour sera décliné en Sprite2DElement et Sprite3DElement et chacun d’eux aura sa version Iso (Iso2DElement et Iso3DElement). La difficulté première est justement ce dupliqua de code obligé par la technique limitée du langage.

Nous avons donc un rendu, complet, chaque objets dessinés et à sa place.

Oh ? Mais que ce passe-t-il ? Ben ça c’est quand on clique sur la porte pour la fermer et que … ben ça plante. Ce qui m’a fait découvrir l’erreur total sur le principe de création des textures (sprites transformé pour WebGL) pour le rendu. En gros, la boucle JE confondais la liste des sprites et les calques de ces sprites, ce qui, vous l’aurez compris, ne fonctionne pas.

La solution n’était pas pour autant aussi trivial que cette définition de l’erreur. L’objet Texture a été transformé en héritage de Sprite, le code de création a été inclus dans cette classe, et l’usage se fait comme Iso2DElement, ce qui est assez comique, mais juste. Faire, défaire, déplacer et recommencer…

Là c’est du grand n’importe quoi ! Non seulement le Héro Squellettore ne tourne plus MAIS en plus son animation fonctionne ! Il ne s’agit donc pas d’un soucis de cache (il n’y a pas de cache en plus). Mais en plus de cela quand on clique pour interagir avec le coffre celui-ci se rend à un arbre, plus ou moins un en bas gauche (variable en plus).

Premier cas : la rotation du héro. Celui-ci est résolu par le cas précédent, la gestion des calques des Sprites, le fait d’inclure la Texture à se niveau, d’y avoir accès et de pouvoir l’appeler au moment du rendu en précisant la rotation manquante jusqu’à lors.

Le second est plus ardu : le clic droit d’interaction fait quelque chose d’incohérent et variable, et c’est surtout cette dernière qui est bizarre. Un code ne change pas tout seul, donc on a quelque chose qui ne va pas et reste silencieux. Et c’est le cas, un try-catch attrapait une erreur dans la fonction de détection de contact avec l’objet (le raycasting), il tombait en erreur sur le cache qui n’existe pas en 3D, donc dur d’évaluer si on touche ou non l’objet si notre référentiel n’existe même pas…

Là, on attaque un chantier, devoir utiliser le cache 2D pour les éléments 3D (sous-entendu les Sprites 2D dans un contexte 3D). Là encore, du fait du langage, obligé de dupliquer un bout de code pour créer le cache à dimension et dessiner, en 2D comme avant, notre objet au complet. On peut se permettre de simplifier car ni la transparence ou la luminosité ne sont utiles pour savoir si on est dessus ou non, autant optimiser. Évidemment on ne fera cette opération que si on veut tester le contact avec notre objet, ce qui ne devrait pas poser de soucis de performances, et de toutes façons on a pas trop le choix sur la technique.

L’optimisation sur la transparence a directement été mise à la racine de la méthode isTouch (détection du contact avec l’objet) pour éviter tant que possible les tests inutiles.

De plus ailleurs, au moment du rendu, on testait la transparence ET la luminosité, mais en rien la luminosité n’affecte le fait d’être affiché ou nous, du coup, j’ai changé en ne gardant que la transparence.

Le contexte du cache et l’initialisation de ce dernier ont été factorisés pour éviter le plus intelligemment possible la répétition de codes et ainsi faciliter la maintenance tout en restant flexible sur la partie variable.

Enfin, ceci ne concerne que le POC, ce que vous voyez actuellement dans les différentes captures, le coffre, qui est une source de lumière pour le moment, était inclus de base. Mais quand je fais mes rendus, pour vous présenter les résultats, je réduis ma fenêtre et donc la taille du subsetMap, et pour une fois, j’ai vu qu’il fonctionnait, en faisant aller mon Héro tout en bas, le coffre est sorti de l’écran, et… pouf ! Freeze complet, je me suis fait engueuler par le navigateur qui me demandait comment utiliser quelque chose de non disponible… :p Oups désolé, on va corriger ça et ajouter une condition ^^. Évidemment on est en POC et tout ce qui concerne les lumières est en chantier, mais ça n’empêche pas de penser à ça. Maintenant c’est fait.

Je peux vous présenter enfin la comparaison avant/après.

À gauche la version précédente en 2D, qui continue de fonctionner en tous points, et à droite la version actuelle en 2D via 3D qui a exactement le même comportement, les performances en plus.

Vous remarquerez qu’en 2D (à gauche) je n’ai pas mis le coffre en source de lumière. C’est la seule différence entre les 2 images. Les arbres sont aléatoires, ça vous l’aviez deviné si vous suivez les articles, donc différence aussi en bas au centre.

Nous voilà donc tel que nous étions il y a plus d’un mois durant les recherches de performances sur les lumières. Nous voilà donc revenu dans une situation « stable » et ainsi pouvoir explorer les 14 millions de suites probables.

Ma première idée, car il manque peu de choses, serait l’impacte couleur de la lumière. Par exemple le coffre émettrait du bleu. Il faudra faire la fusion des couleurs, gérer les impactes individuels et on profiterait pour revoir et finir la propagation des lumières.

Ensuite, il y a bien sur l’éditeur, il faut l’adapter à la 3D et avancer sur l’insertion d’objets sur la carte.

Et puis, enfin, on verra bien l’inspiration du moment. Le but reste le même : le projet HeroQuest; tout en développant le moteur TARS.

Fade to grey(worm)

Outre une petite boutade au Visage de chèvre (GOaT), il sera question ici d’un premier résultat, un retour au gris !

Mais pourquoi donc ? Du gris ? Mais où ça ? Ci-dessous :

Ceci ne vous dit peut-être plus grand chose mais il s’agit du ‘loader’, l’écran de chargement en attendant que la scène courante à venir soit prête. Et oui, il est sur fond gris :).

En fait, l’emphase sur le fond gris représente la finition d’un long processus qui vient de nous occuper ces dernières semaines, derniers mois.

Petit retour en arrière avec les performances au niveau de l’éclairage qui nous a déjà bien embêté et limité notre créativité. J’avais pas envie, mais au final c’est une vraie bonne solution : passer au WebGL.

Évidemment cela ne se fait pas tout seul, ni facilement. Si vous avez lu l’article précédent, vous savez déjà un peu, mais pour résumer : merci au site webglfundamentals.org qui m’a permis de démarrer. On attaque pas ça n’importe comment et le code actuel que je vais vous présenter reste un gros POC non-affiné.

Pour commencer il faut se rendre compte que le contexte WebGL ne travaille pas comme le contexte 2D, du coup, au niveau des classes de TARS et du POC il faut penser une division quelque part, en usage ou en héritage, mais quelque part. Là on se casse déjà les dents.

Le premier gros chantier a été de renommer pour identifier la 2D et séparer là où c’était nécessaire, avec, en bonus, le fait que ça continue de fonctionner…

Ensuite on sort l’huile de coude et on ajoute et duplique les composants pour gérer la partie 3D. Il a fallu donner du mou au Renderer pour lui envoyer des options spécifiques (premultipliedAlpha, alpha) et créer un service (pseudo-temporaire-je-POC-laissez-moi-tranquille) de gestion du contexte 3D avec ses shaders, car ici on y passe directement. Du coup, on ne peut pas démarrer sans que ça soit chargé et prêt, donc un événement à insérer dans le workflow.

De plus, pendant les tests, il a fallu désactiver les scènes ‘world’ et ‘loader’ qui sont IsoScene (isométrique) alors qu’on a pas encore affiché une seule image plate (scène ‘title’). Donc on va peut-être commencer par là.

Après, ça part dans tous les sens, le but est de produire une fonction drawImage tel que celle du contexte 2D, mais à ma sauce permettant son faux polymorphisme ainsi que l’injection d’additifs à destination des shaders tel que la gestion de la transparence, de la luminosité, la modification de couleur, etc. Et quand on a écrit la première version, et corrigée, on a le titre daaboo tel que nous l’avions avant ! Yeah !

Mais bon, pour les IsoElement ce n’est pas aussi simple. En 2D ils ont besoin du contexte 2D, il y a un cache et certaines limitations. En 3D, il n’y a pas de cache, on dessine en temps réel et on a des options via les shaders que la 2D n’a pas. Du coup, on y revient, la découpe 2D/3D et du code spécifique pour chaque.

Au final, tout se passe à l’appel de la fonction drawImage de mon service Sprite3D tout en tenant compte du positionnement XY que 2D fait en 2 temps et en un seul en 3D. Une fois l’appel vers drawImage fait, en gros on a fini et le reste se fait tout seul grâce à l’abstraction mise en place.

Ça dessine mais sans utiliser les attributs de la fonction toujours en développement
On a notre assemblage et correctement positionné !

S’en suit la fameuse finition du fond gris où encore une fois les techniques 2D/3D sont différentes, et donc le ‘clear’ de l’écran avant de faire le rendu. Petit jeu d’héritage et de conditions et hop, on passe la couleur ou une valeur par défaut, en restant générique, et on obtient le résultat tel que montré au début sur fond gris !

La suite se fait impatiente ! Réactiver la scène ‘world’, l’interaction et enfin : la lumière.

Bonus : j’ai eu une idée pour faire varier la lumière en tenant compte du delta du déplacement du personnage et ainsi rendre plus doux le changement de lumière sur chaque Tile. Ceci dit il faudra voir la lourdeur du processus, mais on peut déjà imaginer que nous n’avons besoin de refaire le pathfinding qu’au passage de Tile, et durant le delta l’usage suffit (à préserver donc), mais ça demande un reset de chaque Element quand même, enfin, en WebGL cela n’a plus du tout le même sens, ce qui nous arrange justement !

Shader 15 ans plus tard

Ça fout une claque, mais la dernière fois que j’ai touché du HLSL c’était pour mon travail de fin d’étude en 2005 dans un projet que nous avions baptisé Animator 4D. Bon, ce n’est pas le sujet qui nous amène ici, nous allons plutôt « simplifier » l’usage basique du WEBGL dans un contexte de rendu 2D.

Je vous recommande le très bon site
https://webglfundamentals.org/ ! Basé sur ses articles, et d’autres, je vais vous présenter une simplification de compréhension.

Pour info : je travaille en TypeScript (TS).

Charger une texture

C’est relativement simple si vous avez déjà votre img et son event load prêt. En dedans on va ajouter ceci :

const maTex = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, maTex);
// Let's assume all images are not a power of 2
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
// Loaded: so copy in the texture
gl.bindTexture(gl.TEXTURE_2D, maTex); // TODO why twice ?
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, monImgObjHTML);

On considérera que monImgObjHTML est votre HTMLImageElement chargé et la variable maTex un attribut de classe ou autre accessible.

Les shaders

En fait il vous en faut 2, un qui gère les vertex (vertex shader) et celui qui s’occupe de la couleur (fragment shader). Du coup, vous aurez besoin de vous faire 2 petites fonctions pour vous aider lors de l’init : une de chargement de shader et une de création de programme contenant vos shader. En résumé (détail sur le site de webglfundamentals) nous avons :

createShader(type: number, source: string): WebGLShader {
    const gl = this.context;

    // Create the shader
    const shader: WebGLShader = gl.createShader(type);

    // Set the source code
    gl.shaderSource(shader, source);

    // Compile the shader
    gl.compileShader(shader);

    // Check compilation status
    const success = gl.getShaderParameter(shader, gl.COMPILE_STATUS);
    if (!success) {
      // Something went wrong during compilation; get the error
      throw new Error('could not compile shader: ' + gl.getShaderInfoLog(shader));
    }

    return shader;
  }

  createProgram(vertexShader: WebGLShader, fragmentShader: WebGLShader): WebGLProgram {
    const gl = this.context;

    // Create a program
    const program: WebGLProgram = gl.createProgram();

    // Attach the shaders
    gl.attachShader(program, vertexShader);
    gl.attachShader(program, fragmentShader);

    // Link the program
    gl.linkProgram(program);

    // Check if it linked
    const success = gl.getProgramParameter(program, gl.LINK_STATUS);
    if (!success) {
        // something went wrong with the link
        throw new Error('program filed to link: ' + gl.getProgramInfoLog (program));
    }

    return program;
  }

CreateShader va charger un texte plein (dans ma solution), ou ce que vous voulez, c’est bien foutu; et vous rendre un WebGLShader. Du coup, vous pouvez faire un WebGLProgram qui contiendra vos 2 shaders. Hop le tour est joué.

Évidemment cela demande une fonction d’init’ dans laquelle vous allez préciser les variables de vos shaders et appelez vos 2 fonctions.

maFonctionInit() {
  const vertexShader: WebGLShader = this.createShader(gl.VERTEX_SHADER, vertexShaderTxtCode);
  const fragmentShader: WebGLShader = this.createShader(gl.FRAGMENT_SHADER, fragmentShaderTxtCode);

  // Create program
  const program = createProgram(vertexShader, fragmentShader);

  // Buffers
  const colorLocation = gl.getUniformLocation(program, 'u_color');

  ...
}

Bon du coup on est initialisé, et on suppose que le workflow est bon, donc on peut dessiner 🙂 / faire son rendu !

Le rendu

render() {
  gl.bindTexture(gl.TEXTURE_2D, maTex);
  gl.enable(gl.BLEND);
  gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);

  // Tell WebGL to use our shader program pair
  gl.useProgram(program);
  gl.uniform3fv(colorLocation, new Float32Array([0.0, 0.0, 1.0]));

  ...
}

On lie notre texture, on utilise notre programme et on envoie une valeur.

Bonus je vous ai indiqué pour le support de la transparence (genre votre texture en PNG avec transparence). Pensez au premultipliedAlpha: false pour votre canvas !

Pour la fin de la fonction, webglfundamentals vous donnera toutes les infos nécessaires, y compris des librairies de matrices m3/m4 adaptées et bien foutue.

Vous voilà orienté dans une des directions possible pour atteindre votre objectif de rendu 2D en 3D :p.