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.