Un autre conquérant de l'ombre à Phaser, ou l'utilisation de vélos

Il y a deux ans, j'expérimentais déjà avec des substances fantômes dans Phaser 2D. Lors du dernier Ludum Dare, nous avons soudainement décidé de faire une horreur, et quelle horreur sans ombres ni lumières! J'ai craqué mes articulations ...

... et pas une fichue chose à temps pour LD. Dans le jeu, bien sûr, il y a un peu d'ombre et de lumière, mais c'est un misérable semblant de ce qui était vraiment censé être.

De retour à la maison après avoir envoyé le jeu au concours, j'ai décidé de «fermer la gestalt» et de finir ces ombres malheureuses. Ce qui s'est passé - vous pouvez vous sentir dans le jeu , jouer dans la démo , regarder l'image et lire l'article.

Comme toujours dans de tels cas, cela n'a aucun sens d'essayer d'écrire une solution générale, vous devez vous concentrer sur une situation spécifique. Le monde du jeu peut être représenté sous forme de segments - au moins les entités qui projettent des ombres. Les murs sont des rectangles, les gens sont des rectangles, seulement tournés, le spoiler infernal est un cercle, mais dans le modèle de coupure, il peut être simplifié à une longueur d'un diamètre toujours perpendiculaire à un rayon de lumière.

Il existe plusieurs sources de lumière (20-30), et toutes sont circulaires (projecteurs) et sont situées conditionnellement plus bas que les objets éclairés (de sorte que les ombres peuvent être infinies).

J'ai vu dans ma tête les moyens suivants pour résoudre le problème:

  1. Pour chaque source de lumière, nous construisons une texture de la taille d'un écran (enfin, ou 2 à 4 fois plus petite). Sur cette texture, nous dessinons simplement le trapèze BCC'D ', où A est la source lumineuse, BC est le segment, B'C' est la projection du segment au bord de la texture. Après cela, ces textures sont envoyées au shader, où elles sont mélangées en une seule image.

    L'auteur du jeu de plateforme Celeste a fait quelque chose comme ça, qui est bien écrit dans son article sur medium: medium.com/@NoelFB/remaking-celestes-lighting-3478d6f10bf

    Problèmes: 20-30 textures de taille d'écran qui doivent être redessinées presque chaque image et chargées dans le GPU. Je me souviens que ce fut un processus très, très rapide.

  2. La méthode décrite dans un article sur un habr - habr.com/post/272233 . Pour chaque source de lumière, nous construisons une «carte de profondeur», c'est-à-dire une telle texture, où x = l'angle du «faisceau» par rapport à la source de lumière, y = le numéro de la source de lumière et la couleur == distance de la source à l'obstacle le plus proche. Si nous prenons un pas de 0,7 degrés (360/512) et 32 ​​sources lumineuses, nous obtenons une texture 512x32, qui n'a pas été mise à jour depuis si longtemps.
    (exemple de texture pour un pas de 45 degrés)
  3. La voie secrète que je décrirai à la toute fin

Au final, j'ai opté pour la méthode 2. Cependant, la description de l'article ne me convenait pas jusqu'au bout. Là, la texture a également été construite dans le shader à l'aide d'un rakecast - le shader du cycle est allé de la source de lumière dans la direction du faisceau et a cherché un obstacle. Dans mes expériences passées, j'ai également fait du rakecast dans le shader, et c'était très cher, quoique universel.

«Nous avons seulement des segments dans le modèle», pensais-je, «et 10 à 20 segments tombent dans le rayon de n'importe quelle source de lumière. Je ne peux pas calculer rapidement une carte de distance sur cette base? »

J'ai donc décidé de le faire.

Pour commencer, j'ai simplement affiché à l'écran les murs, le «personnage principal» conditionnel et les sources lumineuses. Autour des sources lumineuses, un cercle de lumière claire et pure découpé dans l'obscurité. Pour l'obtenir:

( démo )

J'ai immédiatement commencé à faire avec le shader pour ne pas me détendre. Il fallait y passer pour chaque source lumineuse ses coordonnées et son rayon d'action (au-delà duquel la lumière n'atteint pas), cela se fait simplement à travers un réseau uniforme. Et puis dans le shader (qui est fragmentaire, qui est réalisé pour chaque pixel de l'écran), il restait à comprendre si le pixel courant se trouvait ou non dans notre cercle éclairé.
class SimpleLightShader extends Phaser.Filter { constructor(game) { super(game); let lightsArray = new Array(MAX_LIGHTS*4); lightsArray.fill(0, 0, lightsArray.length); this.uniforms.lightsCount = {type: '1i', value: 0}; this.uniforms.lights = {type: '4fv', value: lightsArray}; this.fragmentSrc = ` precision highp float; uniform int lightsCount; uniform vec4 lights[${MAX_LIGHTS}]; void main() { float lightness = 0.; for (int i = 0; i < ${MAX_LIGHTS}; i++) { if (i >= lightsCount) break; vec4 light = lights[i]; lightness += step(length(light.xy - gl_FragCoord.xy), light.z); } lightness = clamp(0., 1., lightness); gl_FragColor = mix(vec4(0,0,0,0.5), vec4(0,0,0,0), lightness); } `; } updateLights(lightSources) { this.uniforms.lightsCount.value = lightSources.length; let i = 0; let array = this.uniforms.lights.value; for (let light of lightSources) { array[i++] = light.x; array[i++] = game.world.height - light.y; array[i++] = light.radius; i++; } } } 

Maintenant, nous devons comprendre pour chaque source de lumière quels segments projetteront une ombre. Au contraire, quelles parties des segments - dans la figure ci-dessous, nous ne sommes pas intéressés par les parties "rouges" du segment, car la lumière ne les atteint toujours pas.

Remarque: la définition d'intersection est une sorte d'optimisation préliminaire. Il est nécessaire afin de réduire le temps de traitement ultérieur, en éliminant les gros morceaux de segments au-delà du rayon de la source de lumière. Cela a du sens lorsque nous avons de nombreux segments dont la longueur est bien supérieure au rayon de la «lueur». Si ce n'est pas le cas et que nous avons de nombreux segments courts, il peut être judicieux de ne pas perdre de temps à déterminer l'intersection et à traiter l'intégralité des segments, car gagner du temps ne fonctionne toujours pas.

Pour ce faire, j'ai utilisé la formule bien connue pour trouver l'intersection d'une ligne droite et d'un cercle, dont tout le monde se souvient par cœur d'un cours d'école de géométrie ... dans le monde imaginaire de quelqu'un. Je ne me souvenais tout simplement pas d'elle, j'ai donc dû le rechercher sur Google .

Nous encodons, regardez ce qui s'est passé.
( démo )
Cela semble être la norme. Nous savons maintenant quels segments peuvent projeter une ombre et peuvent effectuer un rakecast.

Ici, nous avons également des options:

  1. Nous allons simplement en cercle dans un cercle, jetons des rayons et cherchons des intersections. La distance jusqu'à l'intersection la plus proche est la valeur dont nous avons besoin
  2. Vous ne pouvez aller qu'aux coins qui se divisent en segments. Après tout, nous connaissons déjà les points, il n'est pas difficile de calculer les angles.
  3. De plus, si nous suivons un segment, nous n'avons pas besoin de projeter de rayons et de calculer les intersections - nous pouvons nous déplacer le long du segment avec le pas souhaité. Voici comment cela fonctionne:


Ici AB- segment (mur), CEst le centre de la source lumineuse, Cd- perpendiculaire au segment.

Soit x- l'angle par rapport à la normale, pour lequel vous devez connaître la distance de la source au segment, X1- pointer sur le segment ABoù le faisceau tombe. Triangle CDX1- rectangulaire Cd- une jambe, et sa longueur est connue et constante pour ce segment, CX1- longueur souhaitée. CX1= fracCDcos(x). Si vous connaissez l'étape à l'avance (et nous la connaissons), vous pouvez pré-calculer le tableau des cosinus inverses et rechercher les distances très rapidement.

Je vais donner un exemple de code pour une telle table. Presque tous les travaux avec coins sont remplacés par des travaux avec index, c'est-à-dire entiers de 0 à N, où N = le nombre de pas dans le cercle (c.-à-d. angle de pas =  frac2 piN)

 class HypTable { constructor(steps = 512, stepAngle = 2*Math.PI/steps) { this.perAngleStep = [1]; for (let i = 1; i < steps/4; i++) { //   pi/2 let ang = i*stepAngle; this.perAngleStep[i] = 1/Math.cos(ang); } this.stepAngle = stepAngle; } /** * @param distancesMap -  ,    * @param angle1 -           * @param angle2 -           * @param normalFromLight - ,      */ fillDistancesForArc(distancesMap, angle1, angle2, normalFromLight) { const D = Math.hypot(normalFromLight.x, normalFromLight.y); const normalAngle = Phaser.Math.normalizeAngle(Math.atan2(normalFromLight.y, normalFromLight.x)); const normalAngleIndex = (normalAngle / this.stepAngle)|0; const index1 = (angle1 / this.stepAngle)|0; const index2 = (angle2 / this.stepAngle)|0; for (let angleIndex = index1; angleIndex <= index2; angleIndex++) { let distanceForAngle = D * this.perAngleStep[normalize(angleIndex - normalAngleIndex)]; distancesMap.set(angleIndex, distanceForAngle); } } } 

Bien sûr, cette méthode introduit une erreur pour les cas où l'angle initial ACD n'est pas un multiple d'une étape. Mais pour 512 étapes, je ne vois visuellement aucune différence.

Donc ce que nous savons déjà faire:
  1. Trouvez des segments dans la plage de la source de lumière qui peuvent projeter une ombre
  2. Pour l'étape t, créez une table dist (angle) en passant par chaque segment et en calculant les distances.


Voici à quoi ressemble ce tableau si vous le dessinez en rayons.

( démo )

Et voici à quoi cela ressemble 10 sources de lumière, si elles sont écrites dans une texture.

Ici, chaque pixel horizontal correspond à un angle, et la couleur à la distance en pixels.
Il est écrit en js comme celui-ci en utilisant imageData
  fillBitmap(data, index) { let total = index + this.steps*4; let d1, d2; let i = 0; //data[index] = Red //data[index+1] = Green //data[index+2] = Blue //data[index+3] = Alpha for (; index < total; index+=4, i++) { //  512,    R     2. d1 = (this.distances[i]/2)|0; data[index] = d1; d1 = this.distances[i] - d1*2; d2 = (d1*128)|0; //   G -     2. data[index+1] = d2; //  B  A  255,     . data[index+2] = 255; data[index+3] = 255; } } 


Maintenant, nous passons la texture à notre shader, qui a déjà les coordonnées et les rayons des sources lumineuses. Et traitez-le comme ceci:

 //      uniform sampler2D iChannel0; #define STRENGTH 0.3 #define MAX_DARK 0.7 #define M_PI 3.141592653589793 #define M_PI2 6.283185307179586 //       float decodeDist(vec4 color) { return color.r*255.*2. + color.g*2.; } float getShadow(int i, float angle, float distance) { //   x   ==  float u = angle/M_PI2; //   y   ==     float v = float(i)/${MAX_LIGHTS}.; float shadowAfterDistance = decodeDist(texture2D(iChannel0, vec2(u, v))); //  1   ,  0  . return step(shadowAfterDistance, distance); } void main() { float lightness = 0.; for (int i = 0; i < ${MAX_LIGHTS}; i++) { if (i >= lightsCount) break; vec4 light = lights[i]; //       vec2 light2point = gl_FragCoord.xy - light.xy; float radius = light.z; float distance = length(light2point); float inLight = step(distance, radius); //      ,       //  . //      , //    ,          //           //     ,    if (inLight == 0.) continue; float angle = mod(-atan(light2point.y, light2point.x), M_PI2); // 1     0   float thisLightness = (1. - getShadow(i, angle, distance)); //,   “”  ,   ,  //    lightness += thisLightness*STRENGTH; } lightness = clamp(0., 1., lightness); gl_FragColor = mix(vec4(0,0,0,MAX_DARK), vec4(0,0,0,0), lightness); } 


Résultat:
( démo )
Maintenant, vous pouvez apporter un peu de beauté. Laissez la lumière s'estomper avec la distance et les ombres seront floues.

Pour le flou, je regarde les coins adjacents, + - étape, comme ceci:

 thisLightness = (1. - getShadow(i, angle, distance)) * 0.4 + (1. - getShadow(i, angle-SMOOTH_STEP, distance)) * 0.2 + (1. - getShadow(i, angle+SMOOTH_STEP, distance)) * 0.2 + (1. - getShadow(i, angle-SMOOTH_STEP*2., distance)) * 0.1 + (1. - getShadow(i, angle+SMOOTH_STEP*2., distance)) * 0.1; 


Si vous assemblez tout et mesurez le FPS, cela se révèle comme ceci:

  • Sur les cartes vidéo intégrées - tout est mauvais (<30-40), même pour des exemples simples
  • Tout le reste va bien, tant que les sources lumineuses ne sont pas très fortes. C'est-à-dire que le nombre de sources de lumière par pixel est important, pas le nombre total.


Ce résultat me convenait tout à fait. Vous pouvez toujours jouer avec la couleur de l'éclairage, mais je ne le fais pas. Après avoir tordu un peu et ajouté des cartes normales, j'ai téléchargé une version mise à jour de NOPE. Elle ressemblait à ceci maintenant:


Puis il a commencé à préparer un article. J'ai regardé un tel gif et j'ai pensé.

«C'est donc presque un look pseudo-3D, comme dans Wolfenstein», m'exclamai-je (oui, j'ai une bonne imagination). Et en fait - si nous supposons que tous les murs ont la même hauteur, les cartes de distance nous suffiront pour construire la scène. Pourquoi ne pas l'essayer?

La scène devrait ressembler à ceci.


Donc notre tâche:

  1. Sur un point de l'écran, obtenez les coordonnées mondiales de l'affaire lorsqu'il n'y a pas de murs.

    Nous considérerons ceci:
    • Tout d'abord, nous normalisons les coordonnées d'un point sur l'écran afin qu'il y ait un point (0,0) au centre de l'écran et aux coins (-1, -1) et (1,1), respectivement
    • La coordonnée x devient l'angle de la direction de la vue, il vous suffit de le multiplier par A / 2, où A est l'angle de vue
    • La coordonnée y détermine la distance de l'observateur au point, dans le cas général d ~ 1 / y. Pour un point sur le bord inférieur de l'écran, distance = 1, pour un point au centre de l'écran, distance = infini.
    • Ainsi, si vous ne prenez pas en compte les murs, alors pour chaque point visible du monde il y aura 2 points sur l'écran - un au dessus du milieu (au «plafond») et l'autre en dessous (au «sol»)
  2. Maintenant, nous pouvons regarder le tableau des distances. S'il y a un mur plus proche que notre point, alors vous devez dessiner un mur. Sinon, cela signifie sol ou plafond

Nous obtenons comme commandé:
( démo )
Ajoutez de l'éclairage - de la même manière, parcourez les sources de lumière et vérifiez les coordonnées du monde. Et - la touche finale - ajoutez des textures. Pour ce faire, dans une texture avec des distances, vous devez également écrire le décalage u pour la texture du mur à ce stade. C'est là que le canal b est devenu utile.
( démo )
Parfait.

Je plaisante.

Imparfait, bien sûr. Mais bon sang, j'ai toujours lu comment faire mon Wolfenstein grâce à rakecast il y a environ 15 ans, et je voulais tout faire, et voici une telle opportunité!

Au lieu d'une conclusion


Au début de l'article, j'ai mentionné une autre méthode secrète. Le voici:

Prenez simplement le moteur qui sait déjà.

En fait, si vous devez créer un jeu, ce sera le moyen le plus correct et le plus rapide. Pourquoi avez-vous besoin de clôturer vos vélos et de résoudre des problèmes de longue date?

Mais pourquoi.

En 10e année, j'ai déménagé dans une autre école et j'ai rencontré des problèmes de mathématiques. Je ne me souviens pas de l'exemple exact, mais c'était une équation avec des degrés, qui à tous égards devait être simplifiée, mais elle n'a tout simplement pas réussi. Désespérée, j'ai consulté ma sœur et elle a dit: «Alors, ajoutez x 2 des deux côtés, et tout se décomposera.» Et c'était la solution: ajoutez ce qui n'était pas là.

Quand, beaucoup plus tard, j'ai aidé mon ami à construire ma maison, j'ai dû mettre un bloc sur le seuil - pour remplir une niche. Et ici je me tiens et trie l'assiette des barres. L'une semble convenir, mais pas tout à fait. D'autres sont beaucoup plus petits. Je réfléchis à la façon de collecter le mot bonheur ici, et un ami dit: "alors ils ont bu les sillons dans un endroit circulaire où il interfère". Et maintenant, la grande barre est déjà immobile.

Ces histoires sont unies par un tel effet, que j'appellerai «l'effet d'inventaire». Lorsque vous essayez de prendre une décision à partir de pièces existantes, sans voir de matériau pouvant être traité et affiné dans ces pièces. Les chiffres sont en bois, en argent ou en code.

Plusieurs fois, j'ai observé le même effet avec des collègues en programmation. Ne se sentant pas confiants dans le matériau, ils cèdent parfois quand il est nécessaire de faire, disons, des contrôles non standard. Ou ajoutez des tests unitaires là où ils n'étaient pas. Ou ils essaient de tout prévoir, tout lors de la conception d'une classe, puis nous obtenons un dialogue comme:
- Ce n'est pas nécessaire maintenant
- Et si cela devient nécessaire?
- Ensuite, nous ajouterons. Laissez les points d'expansion, c'est tout. Le code n'est pas du granit, c'est de la pâte à modeler.

Et pour apprendre à voir et à ressentir le matériau avec lequel nous travaillons, nous avons également besoin de vélos.

Ce n'est pas seulement un entraînement pour l'esprit ou un entraînement. C'est un moyen d'atteindre un niveau de travail qualitativement différent avec le code.

Merci à tous d'avoir lu.

Liens, au cas où vous auriez oublié de cliquer quelque part:

Source: https://habr.com/ru/post/fr434370/


All Articles