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.

Masque splendide

Tel Stanley Ipkiss, TARS joue maintenant avec un masque lors des rendus d’objets occupant plusieurs cases dans notre contexte isométrique. Mais pourquoi ?

La table passe sur le Héro car elle est rendue après.

C’est là qu’il faut vous expliquer comment ça fonctionne, ce qui n’est pas une mince affaire. La table occupe 2 cases, mais comme tout Element, il a une coordonnée qui ici nous dit {x: 8, y: 2} (rappel on commence à zéro) et notre Héro, lui, arrive sur la case {x: 9, y: 1}, ce qui de part notre mécanique de rendu Isométrique dessine le Héro avant la table et donc la table écrase le dessin du Héro. Vous suivez jusque là ?

Ceci illustre bien l’ordre de rendu (dessin), de haut gauche, vers la droite jusqu’en fin de ligne, on passe à la ligne suivante et on repart de gauche à droite.

1 problème ?

On a donc un problème, le Héro devrait être dessiné devant la table, ce qui correspond à notre logique visuelle. Même si on inverse le sens de dessin, en colonne au lieu de ligne, on aura le même soucis avec l’autre table.

Après de longues recherches et essais, une seule solution : la manifestation ! découper l’image en 2 pour occuper chacune une case. Car le problème n’est pas que cette erreur de dessin mais aussi le pathfinder qui passe au travers de la case qui n’est pas affectée (en mémoire), elle ne l’est que par vos yeux; et enfin la lumière qui voit la table tantôt d’un bout (loin), tantôt de l’autre bout (proche) et donc l’éclaire différemment.

3 problèmes ?

Le pathfinder se base sur la présence d’un Element sur la grille que l’on fabrique sur base du subsetMap, et la lumière, chacun ayant son algorithme. Le rendu se base directement sur la subsetMap. On serait tenté de dire qu’il faut solutionner les 3 individuellement et c’est ainsi que j’avais commencé.

J’ai donc ajouté une notion d’additionnalCoords (coordonnées additionnelles) dans la définition de l’élément table et ce pour les 4 orientations NESW, décrivant, dans le cas de notre table orientée vers le Nord et dont la base se trouve en bas gauche de l’image, une case additionnelle en x: 0, y: -1.

Le choix de la coordonnées {0,0} vient du sens de rendu et du sens de détection du raycasting quand on clic droit sur un Element (coffre, porte). La dernière case dominera les précédentes comme démontré par le problème. Le raycasting parcours l’inverse du rendu pour trouver l’élément le plus devant. Notre {0,0} sera donc cette dernière case a être rendue et correspondra à la coordonnée de l’Element, les additionalCoords représente l’ensemble des autres cases en mode relatif (ex: {x: -1, y: 0} pour la table orientée vers l’Est).

Raycasting ? 4 problèmes donc ?!

Maintenant que vous comprenez la structure, le pourquoi du comment et la mécanique céleste, nous allons tenter de corriger les problèmes un à un.

Le plus facile est le pathfinder, qui se base sur des cases occupées pour dire que l’on ne peut s’y rendre. Actuellement le Héro se déplace sur la seconde case de la table « dessous » car la table se dessine après. Il faut donc arriver à lui dire « hey tiens voilà des coordonnées additionnelles à retirer !« .

Aussitôt dit, aussitôt fait, quand on prépare le pathfinder sur base de la subsetMap, on demande à tous les Elements s’ils ont des coordonnées additionnelles et ensuite on les retire du résultat final.

Fin ! 8D

Ah ah ah Oui mais non ! Ça fonctionne, certes, le Héro ne traverse plus les tables, ce n’est plus un drôle de fantôme. Mais ! La lumière n’est pas bonne, le raycasting reste un problème et évidemment notre problème de rendu reste identique, la table passe devant le Héro.

J’ai tenté une théorie visant à dire au GridBlock de prendre en compte des pointeurs, une forme de référence entre cases, mais en vain. Un GridBlock est un multi-ensemble d’Elements, point.

Je vous épargne toute la frustration et brisage de méninges, il m’aura fallu une bonne semaine pour trouver la seule piste envisageable.

Un système de masque

Dit comme ça, ça semble être la solution ultime, et c’est pas loin, mais incomplet. On garde les additionnalCoords et on s’en sert à l’ajout de l’Element sur la Map lors du chargement pour l’ajouter à chacune des cases, le même élément (la même instance, pas une copie). Notre table est donc physiquement présente en mémoire sur 2 cases.

Cela résout le problème de pathfinder et on peut retirer ce que nous avons fait précédemment. Le raycasting est aidé par cette approche mais il faudra l’aider (cf le rendu), nous verrons ça en fin d’article. Il nous reste la lumière et le rendu.

Table ajoutée 2 fois sur la coordonnée de l’Element au lieu des cases spécifiques.
Chacune des 2 cases de la table rend la même image, donc elle « fusionnent » visuellement.

L’idée du masque est de dessiner une partie de l’image sur chaque case correspondante, évitant de demander au graphiste de préparer un grand nombre d’images individuelles et de devoir se battre avec son éditeur de map, ce qui n’est pas gérable.

Chaque case correspond à un rendu à faire, la zone bleue donne B et la zone orange donne A.

Modification du système de rendu

Dans le cas où notre Element a des additionnalCoords il faut appliquer le masque, sinon le rendu classique qui va bien. Pour y arriver, il nous manque quelque chose, comment savoir quelle partie dessiner ? Actuellement nous dessinons selon la coordonnée de l’Element, du coup dans ce cas on dessine une table entière selon ses coordonnées ce qui donne l’effet de fusion illustré ci-avant.

Pour changer ça, il faut dire à notre système de rendu que ce n’est pas la coordonnée de l’Element qu’il faut utiliser, grosso modo. On va commencer par ajouter la notion de coordonnées au gridBlock, qui n’en avait pas besoin jusque là. Et qu’ils passent cette coordonnée à la fonction draw() pour que l’information arrive au système de rendu.

Et après on fait comment ? On se questionne, a-t-on des additionnalCoords ? Si oui, à laquelle correspond le x,y donné par le rendu ? De la on calcul un masque prenant en compte le déplacement dans l’image pour n’afficher que ce qui nous intéresse dans notre cas. Illustrons ça avec la conquête des erreurs de rendu.

Il y a bien un rendu par case, en ayant forcé la largeur de la source à une largeur de case, mais en ayant oublié la déformation de destination.
Correction de la destination, tout le monde à une case de largeur.
Premier résultat concluant.

Le Héro ne passe plus derrière la table ! Mais ?! Qu’est-ce-que c’est que cette drôle de table coupée ? On a un soucis, ok mais lequel ? On dirait que la « fenêtre » du masque n’est pas au bon endroit, mais pourquoi que dans la table orientée à l’Est (à gauche) ?

Pour déterminer ce qu’il se passe, j’ai essayé de dessiner le premier morceau, et on voit que le même problème apparait.

Tentons de voir à quoi ressemble chaque morceau sans considérer la case originale. Nous n’avons pas le même résultat, en vert on a une fenêtre aux bonnes dimensions et bien positionnée, en rouge non.

Pour tenter de comprendre, j’ai modifié la table Est en inversant son origine (en haut gauche au lieu de bas droite) et en modifiant son rendu. J’ai également ajouté un fond à l’image pour comprendre les dimensions gérées.

Inversons le rendu et affichons un fond à la transparence.

En rendu inversé pour la table Est ça fonctionne ! Mais pourquoi ? Qu’est-ce qui change ? Et là une théorie survient, le fait que la base soit au delà d’une distance de case dans l’image quand on calcule le masque, provoque ce décalage. Je vais vous passer les calculs et les correctifs spécifiques au masque, mais en résumé, on doit déplacer la coordonnée source dans l’image selon la théorie isométrique (par demi case en X/Y) mais aussi corriger le résultat par cette même théorie, car il faut rationaliser ce décalage au delà d’une distance d’une case. De plus ce dernier correctif doit être appliqué à l’inverse au positionnement de destination. J’ai tenté un dessin, mais même pour moi il n’a pas été simple de le schématiser pour le coder.

Résultat corrigé du rendu.

On a donc notre Héro devant la table, des tables bien positionnées (celle de l’Est a été déplacée pour les tests) et on a en même temps solutionné la lumière qui éclaire équitablement les 2 morceaux. Ceci est un effet de bord qui tombe à point et qui se base sur le fait que c’est la même instance du même Element qui est référencé dans les 2 cases et donc quand la lumière s’applique, c’est le plus proche qui est choisi et appliqué, par effet de propagation. Enfin, c’est ma déduction car je ne me suis pas amusé à le démontrer.

Bon ok j’ai regardé un petit peu en écrivant ces lignes, il se pourrait que la table aie une addition de quantité d’éclairage par case, il faudra donc vérifier ça et décider du comportement. Vu les formes découpées, je ne suis pas sûr qu’un éclairage non uni soit une bonne idée.

Et le raycasting ?

On y vient, comme dit plus haut, c’est ici que ça se passe. On est vite tenté de dire qu’on a fini car nos problèmes visuels s’en sont allés, mais que nenni, il nous en reste un beaucoup moins visible : le raycasting. Pour ceux qui n’ont pas suivi, le raycasting (lancé de rayon), permet de déterminer dans notre cas ce sur quoi on clic (quand on clic droit sur le coffre, la porte, ou même un arbre).

Donc ici, pour chaque case, il se croit être à l’origine et m’est avis que ça va pas nous aider. Il va falloir, car ce n’est point encore fait, lui expliquer à lui aussi le système de masque. Par extension, on pourra peut-être globaliser et « simplifier ».

Prochaines étapes

En me relisant, je remarque que je parle souvent de l’éditeur mais pas cette fois; ni du jeu que nous allons démarrer comme projet vivant de l’usage de notre moteur TARS, et oui toujours dans le monde de Nahyan, nous y reviendrons prochainement; Mais plutôt vous parler de système de particules et de miroirs qui me sont venu à l’esprit et tant qu’à faire, une démo technique avec une petite explosion dont chaque particule est luminescente et se reflète dans des miroirs…

Smooth crimilight

Hier nous parlions de lumière, de beaux éclairages et de couleurs. Aujourd’hui, nous allons parler de douceur et de déplacement, mais parlons aussi de Disco et d’épilepsie !

Actuellement, la lumière et ses effets sont actualisés à certains moments, nous évitons ainsi les calculs perpétuels inutiles. Mais nous aimerions que la lumière suive notre héro Squellettore quand il se déplace et non pas que quand il traverse une case, ce qui nous donne un clignotement de refresh global quand on change de case. Ça nous fait un effet rétro mais on peut mieux faire.

L’idée est de diviser la lumière en 2 dès que l’on commence à bouger. Une lumière à X% de là où on part et une seconde à Y% là où on va (la case d’à côté dans le sens actuel). La somme donnera un résultat équivalent mais permet de graduellement faire transiter la lumière d’une case à une autre en douceur.

En regardant l’image ci-dessus, en prenant les 2 points vert comme étant le centre de chaque case et en considérant 100% la distance entre les 2, on peut facilement se représenter un delta qui représente la quantité de déplacement au départ de l’origine. Ainsi, tant qu’on est sur la case d’origine on aura X% = 100% – delta et Y% = delta. Je vous épargne les quelques complexités et conditions dues à mon implémentation initiale.

C’est là que le Disco est né au fond d’un donjon… Ça c’est quand modifie la force de la lumière, entre autres… et ce n’est pas ce que l’on veut exactement. Il faudrait altérer la lumière par case, pas sa force.

Pour arriver à cela, j’ai dû améliorer les informations de lumière en ajoutant la notion d’intensité qui altère le résultat sur la force de la lumière. Vous me direz « c’est quoi la différence ? » et je vous dirais que la force donnera la distance à laquelle la lumière éclaire (5 cases par exemple pour Squellettore et 3 pour le coffre) tandis que l’intensité altère la valeur par case uniformément. C’est ce qui a permis de faire glisser la lumière d’une case à l’autre.

Oh douceur du déplacement progressif remplaçant le changement saccadé brutal. Joie !

À cela on ajoute un code refactorisé, simplifié, plus facile à suivre et documenté. Ahhh TODO que j’aime tant… te retirer. C’est bon, on la garde !

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.